Skip to content

Commit 6d494e1

Browse files
committed
Add a base class for creating IDs
Defining new IDs via the new base class is much simpler and concise now, and avoids a lot of code duplication. Please see the module documentation for more details. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 72e0b22 commit 6d494e1

File tree

2 files changed

+163
-103
lines changed

2 files changed

+163
-103
lines changed
Lines changed: 159 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,157 +1,218 @@
11
# License: MIT
22
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
33

4-
"""Strongly typed IDs for microgrids, components and sensors."""
4+
r'''Provides strongly-typed unique identifiers for entities.
55
6+
This module offers a base class,
7+
[`BaseId`][frequenz.client.microgrid.id.BaseId], which can be subclassed to
8+
create distinct ID types for different components or concepts within a system.
9+
These IDs ensure type safety, meaning that an ID for one type of entity (e.g., a
10+
sensor) cannot be mistakenly used where an ID for another type (e.g., a
11+
microgrid) is expected.
612
7-
from typing import final
13+
# Creating Custom ID Types
814
15+
To define a new ID type, create a class that inherits from
16+
[`BaseId`][frequenz.client.microgrid.id.BaseId] and provide a unique
17+
`str_prefix` as a keyword argument in the class definition. This prefix is used
18+
in the string representation of the ID and must be unique across all ID types.
919
10-
@final
11-
class MicrogridId:
12-
"""A unique identifier for a microgrid."""
20+
Note:
21+
The `str_prefix` must be unique across all ID types. If you try to use a
22+
prefix that is already registered, a `ValueError` will be raised when defining
23+
the class.
1324
14-
def __init__(self, id_: int, /) -> None:
15-
"""Initialize this instance.
25+
To encourage consistency, the class name must end with the suffix "Id" (e.g.,
26+
`MyNewId`). This check can be bypassed by passing `allow_custom_name=True` when
27+
defining the class (e.g., `class MyCustomName(BaseId, str_prefix="MCN",
28+
allow_custom_name=True):`).
1629
17-
Args:
18-
id_: The numeric unique identifier of the microgrid.
30+
Tip:
31+
Use the [`@typing.final`][typing.final] decorator to prevent subclassing of
32+
ID classes.
1933
20-
Raises:
21-
ValueError: If the ID is negative.
22-
"""
23-
if id_ < 0:
24-
raise ValueError("Microgrid ID can't be negative.")
25-
self._id = id_
34+
Example: Creating a standard ID type
35+
```python
36+
from typing import final
37+
from frequenz.client.microgrid.id import BaseId
2638
27-
def __int__(self) -> int:
28-
"""Return the numeric ID of this instance."""
29-
return self._id
39+
@final
40+
class InverterId(BaseId, str_prefix="INV"):
41+
"""A unique identifier for an inverter."""
3042
31-
def __eq__(self, other: object) -> bool:
32-
"""Check if this instance is equal to another object."""
33-
# This is not an unidiomatic typecheck, that's an odd name for the check.
34-
# isinstance() returns True for subclasses, which is not what we want here.
35-
# pylint: disable-next=unidiomatic-typecheck
36-
return type(other) is MicrogridId and self._id == other._id
43+
inv_id = InverterId(123)
44+
print(inv_id) # Output: INV123
45+
print(int(inv_id)) # Output: 123
46+
```
3747
38-
def __lt__(self, other: object) -> bool:
39-
"""Check if this instance is less than another object."""
40-
# pylint: disable-next=unidiomatic-typecheck
41-
if type(other) is MicrogridId:
42-
return self._id < other._id
43-
return NotImplemented
48+
Example: Creating an ID type with a non-standard name
49+
```python
50+
from typing import final
51+
from frequenz.client.microgrid.id import BaseId
4452
45-
def __hash__(self) -> int:
46-
"""Return the hash of this instance."""
47-
# We include the class because we explicitly want to avoid the same ID to give
48-
# the same hash for different classes of IDs
49-
return hash((MicrogridId, self._id))
53+
@final
54+
class CustomNameForId(BaseId, str_prefix="CST", allow_custom_name=True):
55+
"""An ID with a custom name, not ending in 'Id'."""
5056
51-
def __repr__(self) -> str:
52-
"""Return the string representation of this instance."""
53-
return f"{type(self).__name__}({self._id!r})"
57+
custom_id = CustomNameForId(456)
58+
print(custom_id) # Output: CST456
59+
print(int(custom_id)) # Output: 456
60+
```
5461
55-
def __str__(self) -> str:
56-
"""Return the short string representation of this instance."""
57-
return f"MID{self._id}"
62+
# Predefined ID Types
5863
64+
This module predefines the following ID types:
5965
60-
@final
61-
class ComponentId:
62-
"""A unique identifier for a microgrid component."""
66+
- [`ComponentId`][frequenz.client.microgrid.id.ComponentId]: For identifying
67+
generic components.
68+
- [`MicrogridId`][frequenz.client.microgrid.id.MicrogridId]: For identifying
69+
microgrids.
70+
- [`SensorId`][frequenz.client.microgrid.id.SensorId]: For identifying sensors.
71+
'''
6372

64-
def __init__(self, id_: int, /) -> None:
65-
"""Initialize this instance.
6673

67-
Args:
68-
id_: The numeric unique identifier of the microgrid component.
74+
from typing import Any, ClassVar, Self, cast, final
6975

70-
Raises:
71-
ValueError: If the ID is negative.
72-
"""
73-
if id_ < 0:
74-
raise ValueError("Component ID can't be negative.")
75-
self._id = id_
7676

77-
def __int__(self) -> int:
78-
"""Return the numeric ID of this instance."""
79-
return self._id
77+
class BaseId:
78+
"""A base class for unique identifiers.
8079
81-
def __eq__(self, other: object) -> bool:
82-
"""Check if this instance is equal to another object."""
83-
# This is not an unidiomatic typecheck, that's an odd name for the check.
84-
# isinstance() returns True for subclasses, which is not what we want here.
85-
# pylint: disable-next=unidiomatic-typecheck
86-
return type(other) is ComponentId and self._id == other._id
80+
Subclasses must provide a unique `str_prefix` keyword argument during
81+
definition, which is used in the string representation of the ID.
8782
88-
def __lt__(self, other: object) -> bool:
89-
"""Check if this instance is less than another object."""
90-
# pylint: disable-next=unidiomatic-typecheck
91-
if type(other) is ComponentId:
92-
return self._id < other._id
93-
return NotImplemented
83+
By default, subclass names must end with "Id". This can be overridden by
84+
passing `allow_custom_name=True` during class definition.
9485
95-
def __hash__(self) -> int:
96-
"""Return the hash of this instance."""
97-
# We include the class because we explicitly want to avoid the same ID to give
98-
# the same hash for different classes of IDs
99-
return hash((ComponentId, self._id))
86+
For more information and examples, see the [module's
87+
documentation][frequenz.client.microgrid.id].
88+
"""
10089

101-
def __repr__(self) -> str:
102-
"""Return the string representation of this instance."""
103-
return f"{type(self).__name__}({self._id!r})"
90+
_id: int
91+
_str_prefix: ClassVar[str]
92+
_registered_prefixes: ClassVar[set[str]] = set()
10493

105-
def __str__(self) -> str:
106-
"""Return the short string representation of this instance."""
107-
return f"CID{self._id}"
94+
def __new__(cls, *_: Any, **__: Any) -> Self:
95+
"""Create a new instance of the ID class, only if it is a subclass of BaseId."""
96+
if cls is BaseId:
97+
raise TypeError("BaseId cannot be instantiated directly. Use a subclass.")
98+
return super().__new__(cls)
10899

100+
def __init_subclass__(
101+
cls,
102+
*,
103+
str_prefix: str,
104+
allow_custom_name: bool = False,
105+
**kwargs: Any,
106+
) -> None:
107+
"""Initialize a subclass, set its string prefix, and perform checks.
109108
110-
@final
111-
class SensorId:
112-
"""A unique identifier for a microgrid sensor."""
109+
Args:
110+
str_prefix: The string prefix for the ID type (e.g., "MID").
111+
Must be unique across all ID types.
112+
allow_custom_name: If True, bypasses the check that the class name
113+
must end with "Id". Defaults to False.
114+
**kwargs: Forwarded to the parent's __init_subclass__.
115+
116+
Raises:
117+
ValueError: If the `str_prefix` is already registered by another
118+
ID type.
119+
TypeError: If `allow_custom_name` is False and the class name
120+
does not end with "Id".
121+
"""
122+
super().__init_subclass__(**kwargs)
123+
124+
if str_prefix in BaseId._registered_prefixes:
125+
raise ValueError(
126+
f"Prefix '{str_prefix}' is already registered. "
127+
"ID prefixes must be unique."
128+
)
129+
BaseId._registered_prefixes.add(str_prefix)
130+
131+
if not allow_custom_name and not cls.__name__.endswith("Id"):
132+
raise TypeError(
133+
f"Class name '{cls.__name__}' for an ID class must end with 'Id' "
134+
"(e.g., 'SomeId'), or use `allow_custom_name=True`."
135+
)
136+
137+
cls._str_prefix = str_prefix
113138

114139
def __init__(self, id_: int, /) -> None:
115140
"""Initialize this instance.
116141
117142
Args:
118-
id_: The numeric unique identifier of the microgrid sensor.
143+
id_: The numeric unique identifier.
119144
120145
Raises:
121146
ValueError: If the ID is negative.
122147
"""
123148
if id_ < 0:
124-
raise ValueError("Sensor ID can't be negative.")
149+
raise ValueError(f"{type(self).__name__} can't be negative.")
125150
self._id = id_
126151

152+
@property
153+
def str_prefix(self) -> str:
154+
"""The prefix used for the string representation of this ID."""
155+
return self._str_prefix
156+
127157
def __int__(self) -> int:
128158
"""Return the numeric ID of this instance."""
129159
return self._id
130160

131161
def __eq__(self, other: object) -> bool:
132-
"""Check if this instance is equal to another object."""
133-
# This is not an unidiomatic typecheck, that's an odd name for the check.
134-
# isinstance() returns True for subclasses, which is not what we want here.
162+
"""Check if this instance is equal to another object.
163+
164+
Equality is defined as being of the exact same type and having the same
165+
underlying ID.
166+
"""
167+
# pylint thinks this is not an unidiomatic typecheck, but in this case
168+
# it is not. isinstance() returns True for subclasses, which is not
169+
# what we want here, as different ID types should never be equal.
135170
# pylint: disable-next=unidiomatic-typecheck
136-
return type(other) is SensorId and self._id == other._id
171+
if type(other) is not type(self):
172+
return NotImplemented
173+
# We already checked type(other) is type(self), but mypy doesn't
174+
# understand that, so we need to cast it to Self.
175+
other_id = cast(Self, other)
176+
return self._id == other_id._id
137177

138178
def __lt__(self, other: object) -> bool:
139-
"""Check if this instance is less than another object."""
179+
"""Check if this instance is less than another object.
180+
181+
Comparison is only defined between instances of the exact same type.
182+
"""
140183
# pylint: disable-next=unidiomatic-typecheck
141-
if type(other) is SensorId:
142-
return self._id < other._id
143-
return NotImplemented
184+
if type(other) is not type(self):
185+
return NotImplemented
186+
other_id = cast(Self, other)
187+
return self._id < other_id._id
144188

145189
def __hash__(self) -> int:
146-
"""Return the hash of this instance."""
147-
# We include the class because we explicitly want to avoid the same ID to give
148-
# the same hash for different classes of IDs
149-
return hash((SensorId, self._id))
190+
"""Return the hash of this instance.
191+
192+
The hash is based on the exact type and the underlying ID to ensure
193+
that IDs of different types but with the same numeric value have different hashes.
194+
"""
195+
return hash((type(self), self._id))
150196

151197
def __repr__(self) -> str:
152198
"""Return the string representation of this instance."""
153199
return f"{type(self).__name__}({self._id!r})"
154200

155201
def __str__(self) -> str:
156202
"""Return the short string representation of this instance."""
157-
return f"SID{self._id}"
203+
return f"{type(self)._str_prefix}{self._id}"
204+
205+
206+
@final
207+
class MicrogridId(BaseId, str_prefix="MID"):
208+
"""A unique identifier for a microgrid."""
209+
210+
211+
@final
212+
class ComponentId(BaseId, str_prefix="CID"):
213+
"""A unique identifier for a microgrid component."""
214+
215+
216+
@final
217+
class SensorId(BaseId, str_prefix="SID"):
218+
"""A unique identifier for a microgrid sensor."""

tests/test_id.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,13 @@ class IdTypeInfo:
1616

1717
id_class: type
1818
str_prefix: str
19-
error_prefix: str
2019

2120

2221
# Define all ID types to test here
2322
ID_TYPES: list[IdTypeInfo] = [
24-
IdTypeInfo(MicrogridId, "MID", "Microgrid"),
25-
IdTypeInfo(ComponentId, "CID", "Component"),
26-
IdTypeInfo(SensorId, "SID", "Sensor"),
23+
IdTypeInfo(MicrogridId, "MID"),
24+
IdTypeInfo(ComponentId, "CID"),
25+
IdTypeInfo(SensorId, "SID"),
2726
]
2827

2928

@@ -42,7 +41,7 @@ def test_valid_id(self, type_info: IdTypeInfo) -> None:
4241

4342
def test_negative_id_raises(self, type_info: IdTypeInfo) -> None:
4443
"""Test that creating a negative ID raises ValueError."""
45-
error_msg = f"{type_info.error_prefix} ID can't be negative"
44+
error_msg = f"{type_info.id_class.__name__} can't be negative"
4645
with pytest.raises(ValueError, match=error_msg):
4746
type_info.id_class(-1)
4847

0 commit comments

Comments
 (0)