frequenz-core 1.3.0__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {frequenz_core-1.3.0/src/frequenz_core.egg-info → frequenz_core-1.4.0}/PKG-INFO +50 -28
  2. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/README.md +22 -0
  3. frequenz_core-1.4.0/RELEASE_NOTES.md +23 -0
  4. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/pyproject.toml +33 -28
  5. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/enum.py +4 -2
  6. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/id.py +18 -2
  7. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/math.py +34 -40
  8. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/typing.py +52 -2
  9. {frequenz_core-1.3.0 → frequenz_core-1.4.0/src/frequenz_core.egg-info}/PKG-INFO +50 -28
  10. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz_core.egg-info/SOURCES.txt +2 -0
  11. frequenz_core-1.4.0/src/frequenz_core.egg-info/requires.txt +49 -0
  12. frequenz_core-1.4.0/src/frequenz_core.egg-info/scm_file_list.json +55 -0
  13. frequenz_core-1.4.0/src/frequenz_core.egg-info/scm_version.json +8 -0
  14. frequenz_core-1.3.0/RELEASE_NOTES.md +0 -9
  15. frequenz_core-1.3.0/src/frequenz_core.egg-info/requires.txt +0 -48
  16. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/LICENSE +0 -0
  17. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/MANIFEST.in +0 -0
  18. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/setup.cfg +0 -0
  19. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/__init__.py +0 -0
  20. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/conftest.py +0 -0
  21. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/datetime.py +0 -0
  22. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/module.py +0 -0
  23. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz/core/py.typed +0 -0
  24. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz_core.egg-info/dependency_links.txt +0 -0
  25. {frequenz_core-1.3.0 → frequenz_core-1.4.0}/src/frequenz_core.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-core
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Core utilities to complement Python's standard library
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Documentation, https://frequenz-floss.github.io/frequenz-core-python/
8
8
  Project-URL: Changelog, https://github.com/frequenz-floss/frequenz-core-python/releases
9
9
  Project-URL: Issues, https://github.com/frequenz-floss/frequenz-core-python/issues
@@ -12,7 +12,6 @@ Project-URL: Support, https://github.com/frequenz-floss/frequenz-core-python/dis
12
12
  Keywords: asyncio,collections,core,datetime,frequenz,lib,library,math,python,stdlib,typing
13
13
  Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: MIT License
16
15
  Classifier: Programming Language :: Python :: 3
17
16
  Classifier: Programming Language :: Python :: 3 :: Only
18
17
  Classifier: Topic :: Software Development :: Libraries
@@ -23,41 +22,42 @@ License-File: LICENSE
23
22
  Requires-Dist: typing-extensions<5,>=4.13.0
24
23
  Provides-Extra: dev-flake8
25
24
  Requires-Dist: flake8==7.3.0; extra == "dev-flake8"
25
+ Requires-Dist: flake8-datetimez==20.10.0; extra == "dev-flake8"
26
26
  Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
27
- Requires-Dist: flake8-pyproject==1.2.3; extra == "dev-flake8"
28
- Requires-Dist: pydoclint==0.6.11; extra == "dev-flake8"
27
+ Requires-Dist: flake8-pyproject==1.2.4; extra == "dev-flake8"
28
+ Requires-Dist: pydoclint==0.9.1; extra == "dev-flake8"
29
29
  Requires-Dist: pydocstyle==6.3.0; extra == "dev-flake8"
30
30
  Provides-Extra: dev-formatting
31
- Requires-Dist: black==25.1.0; extra == "dev-formatting"
32
- Requires-Dist: isort==6.0.1; extra == "dev-formatting"
31
+ Requires-Dist: black==26.5.1; extra == "dev-formatting"
32
+ Requires-Dist: isort==8.0.1; extra == "dev-formatting"
33
33
  Provides-Extra: dev-mkdocs
34
- Requires-Dist: Markdown==3.8.2; extra == "dev-mkdocs"
35
- Requires-Dist: black==25.1.0; extra == "dev-mkdocs"
36
- Requires-Dist: mike==2.1.3; extra == "dev-mkdocs"
37
- Requires-Dist: mkdocs-gen-files==0.5.0; extra == "dev-mkdocs"
38
- Requires-Dist: mkdocs-literate-nav==0.6.2; extra == "dev-mkdocs"
39
- Requires-Dist: mkdocs-macros-plugin==1.3.9; extra == "dev-mkdocs"
40
- Requires-Dist: mkdocs-material==9.6.18; extra == "dev-mkdocs"
41
- Requires-Dist: mkdocstrings[python]==0.30.0; extra == "dev-mkdocs"
42
- Requires-Dist: mkdocstrings-python==1.18.2; extra == "dev-mkdocs"
43
- Requires-Dist: frequenz-repo-config[lib]==0.13.5; extra == "dev-mkdocs"
34
+ Requires-Dist: Markdown==3.10.2; extra == "dev-mkdocs"
35
+ Requires-Dist: black==26.5.1; extra == "dev-mkdocs"
36
+ Requires-Dist: mike==2.2.0; extra == "dev-mkdocs"
37
+ Requires-Dist: mkdocs-gen-files==0.6.1; extra == "dev-mkdocs"
38
+ Requires-Dist: mkdocs-literate-nav==0.6.3; extra == "dev-mkdocs"
39
+ Requires-Dist: mkdocs-macros-plugin==1.5.0; extra == "dev-mkdocs"
40
+ Requires-Dist: mkdocs-material==9.7.7; extra == "dev-mkdocs"
41
+ Requires-Dist: mkdocstrings[python]==1.0.6; extra == "dev-mkdocs"
42
+ Requires-Dist: mkdocstrings-python==2.0.5; extra == "dev-mkdocs"
43
+ Requires-Dist: frequenz-repo-config[lib]==0.18.0; extra == "dev-mkdocs"
44
44
  Provides-Extra: dev-mypy
45
- Requires-Dist: mypy==1.17.1; extra == "dev-mypy"
46
- Requires-Dist: types-Markdown==3.8.0.20250809; extra == "dev-mypy"
45
+ Requires-Dist: mypy==2.3.0; extra == "dev-mypy"
46
+ Requires-Dist: types-Markdown==3.10.2.20260712; extra == "dev-mypy"
47
47
  Requires-Dist: frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
48
48
  Provides-Extra: dev-noxfile
49
- Requires-Dist: nox==2025.5.1; extra == "dev-noxfile"
50
- Requires-Dist: frequenz-repo-config[lib]==0.13.5; extra == "dev-noxfile"
49
+ Requires-Dist: nox==2026.7.11; extra == "dev-noxfile"
50
+ Requires-Dist: frequenz-repo-config[lib]==0.18.0; extra == "dev-noxfile"
51
51
  Provides-Extra: dev-pylint
52
52
  Requires-Dist: frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
53
53
  Provides-Extra: dev-pytest
54
- Requires-Dist: pytest==8.4.1; extra == "dev-pytest"
55
- Requires-Dist: pylint==3.3.8; extra == "dev-pytest"
56
- Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.13.5; extra == "dev-pytest"
57
- Requires-Dist: pytest-mock==3.14.1; extra == "dev-pytest"
58
- Requires-Dist: pytest-asyncio==1.1.0; extra == "dev-pytest"
59
- Requires-Dist: async-solipsism==0.8; extra == "dev-pytest"
60
- Requires-Dist: hypothesis==6.138.11; extra == "dev-pytest"
54
+ Requires-Dist: pytest==9.1.1; extra == "dev-pytest"
55
+ Requires-Dist: pylint==4.0.6; extra == "dev-pytest"
56
+ Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.18.0; extra == "dev-pytest"
57
+ Requires-Dist: pytest-mock==3.15.1; extra == "dev-pytest"
58
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "dev-pytest"
59
+ Requires-Dist: async-solipsism==0.9; extra == "dev-pytest"
60
+ Requires-Dist: hypothesis==6.163.0; extra == "dev-pytest"
61
61
  Provides-Extra: dev
62
62
  Requires-Dist: frequenz-core[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]; extra == "dev"
63
63
  Dynamic: license-file
@@ -200,6 +200,28 @@ class ApiClient:
200
200
  client = ApiClient.create("my-api-key") # ✅ Works
201
201
  ```
202
202
 
203
+ Annotate floating point values honestly, as Python's numeric tower lets `int`
204
+ values through any `float` annotation:
205
+
206
+ ```python
207
+ from typing import assert_never
208
+
209
+ from frequenz.core.typing import FloatInt
210
+
211
+ def describe(value: FloatInt | None) -> str:
212
+ match value:
213
+ case float() | int():
214
+ return f"number {value}"
215
+ case None:
216
+ return "nothing"
217
+ case unexpected:
218
+ assert_never(unexpected)
219
+
220
+ assert describe(1) == "number 1" # ✅ `case float():` alone would crash here
221
+ assert describe(1.5) == "number 1.5"
222
+ assert describe(None) == "nothing"
223
+ ```
224
+
203
225
  ### Strongly-Typed IDs
204
226
 
205
227
  Create type-safe identifiers for different entities:
@@ -136,6 +136,28 @@ class ApiClient:
136
136
  client = ApiClient.create("my-api-key") # ✅ Works
137
137
  ```
138
138
 
139
+ Annotate floating point values honestly, as Python's numeric tower lets `int`
140
+ values through any `float` annotation:
141
+
142
+ ```python
143
+ from typing import assert_never
144
+
145
+ from frequenz.core.typing import FloatInt
146
+
147
+ def describe(value: FloatInt | None) -> str:
148
+ match value:
149
+ case float() | int():
150
+ return f"number {value}"
151
+ case None:
152
+ return "nothing"
153
+ case unexpected:
154
+ assert_never(unexpected)
155
+
156
+ assert describe(1) == "number 1" # ✅ `case float():` alone would crash here
157
+ assert describe(1.5) == "number 1.5"
158
+ assert describe(None) == "nothing"
159
+ ```
160
+
139
161
  ### Strongly-Typed IDs
140
162
 
141
163
  Create type-safe identifiers for different entities:
@@ -0,0 +1,23 @@
1
+ # Frequenz Core Library Release Notes
2
+
3
+ ## Upgrading
4
+
5
+ - [`Interval`][frequenz.core.math.Interval]'s type parameter no longer includes `None`. The exported type variable `LessThanComparableOrNoneT` (bound to `LessThanComparable | None`) was deprecated and replaced with `LessThanComparableT` (bound to `LessThanComparable`). `None` is still accepted as a value for `start` / `end` to indicate an unbounded side, but it is treated purely as bound metadata rather than a value in the interval's comparable space.
6
+
7
+ Migration:
8
+
9
+ - `Interval[int | None]` → `Interval[int]`
10
+ - `Interval[LessThanComparable | None]` → `Interval[LessThanComparable]`
11
+ - `LessThanComparableOrNoneT` (importable name) → `LessThanComparableT`
12
+
13
+ Note that explicitly using `Interval[int | None]` still works, as `(int | None) | None` is equivalent to `int | None`, even when it is no longer needed nor recommended. Old code will mostly work, but there is one soft breaking change: `None in some_interval` and `None in some_interval_set` is now a type-check error at call sites, matching the design intent that `None` is a bound marker and never a member value.
14
+
15
+ ## New Features
16
+
17
+ - Added [`FloatInt`][frequenz.core.typing.FloatInt], a type alias for `float | int`.
18
+
19
+ [PEP 484](https://peps.python.org/pep-0484/)'s [numeric tower](https://peps.python.org/pep-0484/#the-numeric-tower) makes `int` assignable wherever `float` is annotated, while at runtime `isinstance(1, float)` is `False`. A plain `float` annotation therefore silently admits values that break `match … case float():` arms and `float`-only methods like `hex()`.
20
+
21
+ Annotate with `FloatInt` instead of a plain `float`: the alias docstring documents the trap in detail, including the inherent `bool ⊂ int` leak.
22
+
23
+ - [`is_close_to_zero()`][frequenz.core.math.is_close_to_zero] now annotates its `value` and `abs_tol` parameters as [`FloatInt`][frequenz.core.typing.FloatInt]. This is a pure widening, `int` arguments were always accepted by type checkers, the annotation just didn't admit it.
@@ -3,9 +3,9 @@
3
3
 
4
4
  [build-system]
5
5
  requires = [
6
- "setuptools == 80.9.0",
7
- "setuptools_scm[toml] == 9.2.0",
8
- "frequenz-repo-config[lib] == 0.13.5",
6
+ "setuptools == 83.0.0",
7
+ "setuptools_scm[toml] == 10.2.1",
8
+ "frequenz-repo-config[lib] == 0.18.0",
9
9
  ]
10
10
  build-backend = "setuptools.build_meta"
11
11
 
@@ -13,7 +13,8 @@ build-backend = "setuptools.build_meta"
13
13
  name = "frequenz-core"
14
14
  description = "Core utilities to complement Python's standard library"
15
15
  readme = "README.md"
16
- license = { text = "MIT" }
16
+ license = "MIT"
17
+ license-files = ["LICENSE"]
17
18
  keywords = [
18
19
  "asyncio",
19
20
  "collections",
@@ -30,7 +31,6 @@ keywords = [
30
31
  classifiers = [
31
32
  "Development Status :: 5 - Production/Stable",
32
33
  "Intended Audience :: Developers",
33
- "License :: OSI Approved :: MIT License",
34
34
  "Programming Language :: Python :: 3",
35
35
  "Programming Language :: Python :: 3 :: Only",
36
36
  "Topic :: Software Development :: Libraries",
@@ -47,44 +47,45 @@ email = "floss@frequenz.com"
47
47
  [project.optional-dependencies]
48
48
  dev-flake8 = [
49
49
  "flake8 == 7.3.0",
50
+ "flake8-datetimez == 20.10.0",
50
51
  "flake8-docstrings == 1.7.0",
51
- "flake8-pyproject == 1.2.3", # For reading the flake8 config from pyproject.toml
52
- "pydoclint == 0.6.11",
52
+ "flake8-pyproject == 1.2.4", # For reading the flake8 config from pyproject.toml
53
+ "pydoclint == 0.9.1",
53
54
  "pydocstyle == 6.3.0",
54
55
  ]
55
- dev-formatting = ["black == 25.1.0", "isort == 6.0.1"]
56
+ dev-formatting = ["black == 26.5.1", "isort == 8.0.1"]
56
57
  dev-mkdocs = [
57
- "Markdown == 3.8.2",
58
- "black == 25.1.0",
59
- "mike == 2.1.3",
60
- "mkdocs-gen-files == 0.5.0",
61
- "mkdocs-literate-nav == 0.6.2",
62
- "mkdocs-macros-plugin == 1.3.9",
63
- "mkdocs-material == 9.6.18",
64
- "mkdocstrings[python] == 0.30.0",
65
- "mkdocstrings-python == 1.18.2",
66
- "frequenz-repo-config[lib] == 0.13.5",
58
+ "Markdown == 3.10.2",
59
+ "black == 26.5.1",
60
+ "mike == 2.2.0",
61
+ "mkdocs-gen-files == 0.6.1",
62
+ "mkdocs-literate-nav == 0.6.3",
63
+ "mkdocs-macros-plugin == 1.5.0",
64
+ "mkdocs-material == 9.7.7",
65
+ "mkdocstrings[python] == 1.0.6",
66
+ "mkdocstrings-python == 2.0.5",
67
+ "frequenz-repo-config[lib] == 0.18.0",
67
68
  ]
68
69
  dev-mypy = [
69
- "mypy == 1.17.1",
70
- "types-Markdown == 3.8.0.20250809",
70
+ "mypy == 2.3.0",
71
+ "types-Markdown == 3.10.2.20260712",
71
72
  # For checking the noxfile, docs/ script, and tests
72
73
  "frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]",
73
74
  ]
74
- dev-noxfile = ["nox == 2025.5.1", "frequenz-repo-config[lib] == 0.13.5"]
75
+ dev-noxfile = ["nox == 2026.7.11", "frequenz-repo-config[lib] == 0.18.0"]
75
76
  dev-pylint = [
76
77
  # dev-pytest already defines a dependency to pylint because of the examples
77
78
  # For checking the noxfile, docs/ script, and tests
78
79
  "frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]",
79
80
  ]
80
81
  dev-pytest = [
81
- "pytest == 8.4.1",
82
- "pylint == 3.3.8", # We need this to check for the examples
83
- "frequenz-repo-config[extra-lint-examples] == 0.13.5",
84
- "pytest-mock == 3.14.1",
85
- "pytest-asyncio == 1.1.0",
86
- "async-solipsism == 0.8",
87
- "hypothesis == 6.138.11",
82
+ "pytest == 9.1.1",
83
+ "pylint == 4.0.6", # We need this to check for the examples
84
+ "frequenz-repo-config[extra-lint-examples] == 0.18.0",
85
+ "pytest-mock == 3.15.1",
86
+ "pytest-asyncio == 1.4.0",
87
+ "async-solipsism == 0.9",
88
+ "hypothesis == 6.163.0",
88
89
  ]
89
90
  dev = [
90
91
  "frequenz-core[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]",
@@ -123,6 +124,10 @@ check-yield-types = false
123
124
  arg-type-hints-in-docstring = false
124
125
  arg-type-hints-in-signature = true
125
126
  allow-init-docstring = true
127
+ check-class-attributes = true
128
+ check-style-mismatch = true
129
+ require-inline-class-var-docs = true
130
+ skip-checking-short-docstrings = true
126
131
 
127
132
  [tool.pylint.similarities]
128
133
  ignore-comments = ['yes']
@@ -76,11 +76,13 @@ class DeprecatingEnumType(enum.EnumType):
76
76
 
77
77
  Tip:
78
78
  Normally it is not necessary to use this class directly, use
79
- [`Enum`][frequenz.core.enum.Enum] instead.
79
+ [`Enum`][..Enum] instead.
80
80
 
81
81
  Behavior:
82
82
 
83
83
  - In the class body, members may be declared as `NAME = DeprecatedMember(value, msg)`.
84
+ See [`DeprecatedMember`][..DeprecatedMember] and
85
+ [`deprecated_member()`][..deprecated_member] for details.
84
86
  - During class creation, these wrappers are replaced with `value` so that
85
87
  a normal enum member or alias is created by [`EnumType`][enum.EnumType].
86
88
  - The deprecated names are recorded so that:
@@ -98,7 +100,7 @@ class DeprecatingEnumType(enum.EnumType):
98
100
  classdict: Mapping[str, Any],
99
101
  **kw: Any,
100
102
  ) -> type[EnumT]:
101
- """Create the new enum class, rewriting `DeprecatedMember` instances."""
103
+ """Create the new enum class rewriting [`DeprecatedMember`][...DeprecatedMember] members."""
102
104
  deprecated_names: dict[str, str] = {}
103
105
  prepared = super().__prepare__(name, bases, **kw)
104
106
 
@@ -20,7 +20,7 @@ the ID and must be unique across all ID types.
20
20
 
21
21
  Note:
22
22
  The `str_prefix` must be unique across all ID types. If you try to use a
23
- prefix that is already registered, a `ValueError` will be raised when defining
23
+ prefix that is already registered, a warning will be logged when defining
24
24
  the class.
25
25
 
26
26
  To encourage consistency, the class name must end with the suffix "Id" (e.g.,
@@ -61,7 +61,6 @@ Example: Creating an ID type with a non-standard name
61
61
  ```
62
62
  '''
63
63
 
64
-
65
64
  import logging
66
65
  from typing import Any, ClassVar, Self, cast
67
66
 
@@ -157,6 +156,13 @@ class BaseId:
157
156
 
158
157
  Equality is defined as being of the exact same type and having the same
159
158
  underlying ID.
159
+
160
+ Args:
161
+ other: The object to compare against.
162
+
163
+ Returns:
164
+ True if `other` is of the same type and has the same underlying ID,
165
+ `NotImplemented` if `other` is of a different type.
160
166
  """
161
167
  # pylint thinks this is not an unidiomatic typecheck, but in this case
162
168
  # it is not. isinstance() returns True for subclasses, which is not
@@ -173,6 +179,13 @@ class BaseId:
173
179
  """Check if this instance is less than another object.
174
180
 
175
181
  Comparison is only defined between instances of the exact same type.
182
+
183
+ Args:
184
+ other: The object to compare against.
185
+
186
+ Returns:
187
+ True if this instance is less than `other`, `NotImplemented` if
188
+ `other` is of a different type.
176
189
  """
177
190
  # pylint: disable-next=unidiomatic-typecheck
178
191
  if type(other) is not type(self):
@@ -185,6 +198,9 @@ class BaseId:
185
198
 
186
199
  The hash is based on the exact type and the underlying ID to ensure
187
200
  that IDs of different types but with the same numeric value have different hashes.
201
+
202
+ Returns:
203
+ The hash of this instance.
188
204
  """
189
205
  return hash((type(self), self._id))
190
206
 
@@ -5,10 +5,12 @@
5
5
 
6
6
  import math
7
7
  from dataclasses import dataclass
8
- from typing import Generic, Protocol, Self, TypeVar, cast
8
+ from typing import Generic, Protocol, Self, TypeVar
9
9
 
10
+ from .typing import FloatInt
10
11
 
11
- def is_close_to_zero(value: float, abs_tol: float = 1e-9) -> bool:
12
+
13
+ def is_close_to_zero(value: FloatInt, abs_tol: FloatInt = 1e-9) -> bool:
12
14
  """Check if a floating point value is close to zero.
13
15
 
14
16
  A value of 1e-9 is a commonly used absolute tolerance to balance precision
@@ -34,76 +36,68 @@ class LessThanComparable(Protocol):
34
36
  """Return whether self is less than other."""
35
37
 
36
38
 
39
+ LessThanComparableT = TypeVar("LessThanComparableT", bound=LessThanComparable)
40
+ """Type variable for a value that is [`LessThanComparable`][..LessThanComparable]."""
41
+
42
+
37
43
  LessThanComparableOrNoneT = TypeVar(
38
44
  "LessThanComparableOrNoneT", bound=LessThanComparable | None
39
45
  )
40
- """Type variable for a value that a `LessThanComparable` or `None`."""
46
+ """Type variable for a value that is [`LessThanComparable`][..LessThanComparable] or `None`.
47
+
48
+ Warning: Deprecated
49
+ This type variable is deprecated and it will be removed in a future version. Use
50
+ [`LessThanComparableT`][..LessThanComparableT] instead.
51
+ """
41
52
 
42
53
 
43
54
  @dataclass(frozen=True, repr=False)
44
- class Interval(Generic[LessThanComparableOrNoneT]):
55
+ class Interval(Generic[LessThanComparableT]):
45
56
  """An interval to test if a value is within its limits.
46
57
 
47
- The `start` and `end` are inclusive, meaning that the `start` and `end` limites are
48
- included in the range when checking if a value is contained by the interval.
58
+ The [`.start`][.start] and [`.end`][.end] are inclusive, meaning that the
59
+ [`.start`][.start] and [`.end`][.end] limits are included in the range when
60
+ checking if a value is contained by the interval.
49
61
 
50
- If the `start` or `end` is `None`, it means that the interval is unbounded in that
51
- direction.
62
+ If the [`.start`][.start] or [`.end`][.end] is `None`, it means that the interval
63
+ is unbounded in that direction. `None` is used purely as a bound marker; it is
64
+ never a value in the interval.
52
65
 
53
- If `start` is bigger than `end`, a `ValueError` is raised.
66
+ If [`.start`][.start] is bigger than [`.end`][.end], a `ValueError` is raised.
54
67
 
55
68
  The type stored in the interval must be comparable, meaning that it must implement
56
69
  the `__lt__` method to be able to compare values.
57
70
  """
58
71
 
59
- start: LessThanComparableOrNoneT
60
- """The start of the interval."""
72
+ start: LessThanComparableT | None
73
+ """The start of the interval, or `None` to indicate no lower bound (-∞)."""
61
74
 
62
- end: LessThanComparableOrNoneT
63
- """The end of the interval."""
75
+ end: LessThanComparableT | None
76
+ """The end of the interval, or `None` to indicate no upper bound (+∞)."""
64
77
 
65
78
  def __post_init__(self) -> None:
66
79
  """Check if the start is less than or equal to the end."""
67
80
  if self.start is None or self.end is None:
68
81
  return
69
- start = cast(LessThanComparable, self.start)
70
- end = cast(LessThanComparable, self.end)
71
- if start > end:
82
+ if self.start > self.end:
72
83
  raise ValueError(
73
84
  f"The start ({self.start}) can't be bigger than end ({self.end})"
74
85
  )
75
86
 
76
- def __contains__(self, item: LessThanComparableOrNoneT) -> bool:
77
- """
78
- Check if the value is within the range of the container.
87
+ def __contains__(self, item: LessThanComparableT) -> bool:
88
+ """Check if the value is within the range of the interval.
79
89
 
80
90
  Args:
81
91
  item: The value to check.
82
92
 
83
93
  Returns:
84
- bool: True if value is within the range, otherwise False.
94
+ True if value is within the range, otherwise False.
85
95
  """
86
- if item is None:
96
+ if self.start is not None and item < self.start:
97
+ return False
98
+ if self.end is not None and item > self.end:
87
99
  return False
88
- casted_item = cast(LessThanComparable, item)
89
-
90
- if self.start is None and self.end is None:
91
- return True
92
- if self.start is None:
93
- start = cast(LessThanComparable, self.end)
94
- return not casted_item > start
95
- if self.end is None:
96
- return not self.start > item
97
- # mypy seems to get confused here, not being able to narrow start and end to
98
- # just LessThanComparable, complaining with:
99
- # error: Unsupported left operand type for <= (some union)
100
- # But we know if they are not None, they should be LessThanComparable, and
101
- # actually mypy is being able to figure it out in the lines above, just not in
102
- # this one, so it should be safe to cast.
103
- return not (
104
- casted_item < cast(LessThanComparable, self.start)
105
- or casted_item > cast(LessThanComparable, self.end)
106
- )
100
+ return True
107
101
 
108
102
  def __repr__(self) -> str:
109
103
  """Return a string representation of this instance."""
@@ -11,10 +11,60 @@ It also provides a metaclass used by the decorator to disable the `__init__`:
11
11
  [`NoInitConstructibleMeta`][frequenz.core.typing.NoInitConstructibleMeta]. This is
12
12
  useful mostly for disabling `__init__` while having to use another metaclass too (like
13
13
  [`abc.ABCMeta`][abc.ABCMeta]).
14
+
15
+ Finally, it provides [`FloatInt`][frequenz.core.typing.FloatInt], an honest type alias
16
+ for `float | int`, to annotate floating point values that can also be an `int` at
17
+ runtime.
14
18
  """
15
19
 
16
20
  from collections.abc import Callable
17
- from typing import Any, NoReturn, TypeVar, cast, overload
21
+ from typing import Any, NoReturn, TypeAlias, TypeVar, cast, overload
22
+
23
+ FloatInt: TypeAlias = float | int
24
+ """A floating point value that can also be an `int` at runtime.
25
+
26
+ [PEP 484's numeric tower](https://peps.python.org/pep-0484/#the-numeric-tower) makes
27
+ `int` assignable to any `float`-annotated parameter, attribute or variable, so a plain
28
+ `float` annotation is a lie: type checkers (even `mypy --strict`) happily accept `int`
29
+ values, but `isinstance(1, float)` is `False` at runtime. This breaks `match … case
30
+ float():` arms (an `int` value falls through to
31
+ [`assert_never()`][typing.assert_never]), calls to `float`-only methods like
32
+ [`hex()`][float.hex], and any other code dispatching on the concrete runtime type.
33
+
34
+ Annotating with this alias instead makes the heterogeneity explicit, so type checkers
35
+ push the code reading these values to handle both branches, typically by matching with
36
+ `case float() | int():`.
37
+
38
+ The full analysis, including the alternatives that were rejected, is recorded in
39
+ [issue #250](https://github.com/frequenz-floss/frequenz-client-common-python/issues/250).
40
+
41
+ Danger:
42
+ `bool` is a subclass of `int`, so `True` and `False` also satisfy this alias. This
43
+ is inherent to Python's type system and is not guarded against.
44
+
45
+ Example:
46
+ ```python
47
+ from typing import assert_never
48
+
49
+ from frequenz.core.typing import FloatInt
50
+
51
+
52
+ def describe(value: FloatInt | None) -> str:
53
+ match value:
54
+ case float() | int():
55
+ return f"number {value}"
56
+ case None:
57
+ return "nothing"
58
+ case unexpected:
59
+ assert_never(unexpected)
60
+
61
+
62
+ assert describe(1) == "number 1"
63
+ assert describe(1.5) == "number 1.5"
64
+ assert describe(None) == "nothing"
65
+ ```
66
+ """
67
+
18
68
 
19
69
  TypeT = TypeVar("TypeT", bound=type)
20
70
  """A type variable that is bound to a type."""
@@ -207,7 +257,7 @@ class NoInitConstructibleMeta(type):
207
257
  class NoInitConstructibleABCMeta(ABCMeta, NoInitConstructibleMeta):
208
258
  pass
209
259
 
210
- class MyAbstractClass(metaclas=NoInitConstructibleABCMeta):
260
+ class MyAbstractClass(metaclass=NoInitConstructibleABCMeta):
211
261
  @abstractmethod
212
262
  def do_something(self) -> None:
213
263
  ...
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-core
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Core utilities to complement Python's standard library
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Documentation, https://frequenz-floss.github.io/frequenz-core-python/
8
8
  Project-URL: Changelog, https://github.com/frequenz-floss/frequenz-core-python/releases
9
9
  Project-URL: Issues, https://github.com/frequenz-floss/frequenz-core-python/issues
@@ -12,7 +12,6 @@ Project-URL: Support, https://github.com/frequenz-floss/frequenz-core-python/dis
12
12
  Keywords: asyncio,collections,core,datetime,frequenz,lib,library,math,python,stdlib,typing
13
13
  Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: MIT License
16
15
  Classifier: Programming Language :: Python :: 3
17
16
  Classifier: Programming Language :: Python :: 3 :: Only
18
17
  Classifier: Topic :: Software Development :: Libraries
@@ -23,41 +22,42 @@ License-File: LICENSE
23
22
  Requires-Dist: typing-extensions<5,>=4.13.0
24
23
  Provides-Extra: dev-flake8
25
24
  Requires-Dist: flake8==7.3.0; extra == "dev-flake8"
25
+ Requires-Dist: flake8-datetimez==20.10.0; extra == "dev-flake8"
26
26
  Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
27
- Requires-Dist: flake8-pyproject==1.2.3; extra == "dev-flake8"
28
- Requires-Dist: pydoclint==0.6.11; extra == "dev-flake8"
27
+ Requires-Dist: flake8-pyproject==1.2.4; extra == "dev-flake8"
28
+ Requires-Dist: pydoclint==0.9.1; extra == "dev-flake8"
29
29
  Requires-Dist: pydocstyle==6.3.0; extra == "dev-flake8"
30
30
  Provides-Extra: dev-formatting
31
- Requires-Dist: black==25.1.0; extra == "dev-formatting"
32
- Requires-Dist: isort==6.0.1; extra == "dev-formatting"
31
+ Requires-Dist: black==26.5.1; extra == "dev-formatting"
32
+ Requires-Dist: isort==8.0.1; extra == "dev-formatting"
33
33
  Provides-Extra: dev-mkdocs
34
- Requires-Dist: Markdown==3.8.2; extra == "dev-mkdocs"
35
- Requires-Dist: black==25.1.0; extra == "dev-mkdocs"
36
- Requires-Dist: mike==2.1.3; extra == "dev-mkdocs"
37
- Requires-Dist: mkdocs-gen-files==0.5.0; extra == "dev-mkdocs"
38
- Requires-Dist: mkdocs-literate-nav==0.6.2; extra == "dev-mkdocs"
39
- Requires-Dist: mkdocs-macros-plugin==1.3.9; extra == "dev-mkdocs"
40
- Requires-Dist: mkdocs-material==9.6.18; extra == "dev-mkdocs"
41
- Requires-Dist: mkdocstrings[python]==0.30.0; extra == "dev-mkdocs"
42
- Requires-Dist: mkdocstrings-python==1.18.2; extra == "dev-mkdocs"
43
- Requires-Dist: frequenz-repo-config[lib]==0.13.5; extra == "dev-mkdocs"
34
+ Requires-Dist: Markdown==3.10.2; extra == "dev-mkdocs"
35
+ Requires-Dist: black==26.5.1; extra == "dev-mkdocs"
36
+ Requires-Dist: mike==2.2.0; extra == "dev-mkdocs"
37
+ Requires-Dist: mkdocs-gen-files==0.6.1; extra == "dev-mkdocs"
38
+ Requires-Dist: mkdocs-literate-nav==0.6.3; extra == "dev-mkdocs"
39
+ Requires-Dist: mkdocs-macros-plugin==1.5.0; extra == "dev-mkdocs"
40
+ Requires-Dist: mkdocs-material==9.7.7; extra == "dev-mkdocs"
41
+ Requires-Dist: mkdocstrings[python]==1.0.6; extra == "dev-mkdocs"
42
+ Requires-Dist: mkdocstrings-python==2.0.5; extra == "dev-mkdocs"
43
+ Requires-Dist: frequenz-repo-config[lib]==0.18.0; extra == "dev-mkdocs"
44
44
  Provides-Extra: dev-mypy
45
- Requires-Dist: mypy==1.17.1; extra == "dev-mypy"
46
- Requires-Dist: types-Markdown==3.8.0.20250809; extra == "dev-mypy"
45
+ Requires-Dist: mypy==2.3.0; extra == "dev-mypy"
46
+ Requires-Dist: types-Markdown==3.10.2.20260712; extra == "dev-mypy"
47
47
  Requires-Dist: frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
48
48
  Provides-Extra: dev-noxfile
49
- Requires-Dist: nox==2025.5.1; extra == "dev-noxfile"
50
- Requires-Dist: frequenz-repo-config[lib]==0.13.5; extra == "dev-noxfile"
49
+ Requires-Dist: nox==2026.7.11; extra == "dev-noxfile"
50
+ Requires-Dist: frequenz-repo-config[lib]==0.18.0; extra == "dev-noxfile"
51
51
  Provides-Extra: dev-pylint
52
52
  Requires-Dist: frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
53
53
  Provides-Extra: dev-pytest
54
- Requires-Dist: pytest==8.4.1; extra == "dev-pytest"
55
- Requires-Dist: pylint==3.3.8; extra == "dev-pytest"
56
- Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.13.5; extra == "dev-pytest"
57
- Requires-Dist: pytest-mock==3.14.1; extra == "dev-pytest"
58
- Requires-Dist: pytest-asyncio==1.1.0; extra == "dev-pytest"
59
- Requires-Dist: async-solipsism==0.8; extra == "dev-pytest"
60
- Requires-Dist: hypothesis==6.138.11; extra == "dev-pytest"
54
+ Requires-Dist: pytest==9.1.1; extra == "dev-pytest"
55
+ Requires-Dist: pylint==4.0.6; extra == "dev-pytest"
56
+ Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.18.0; extra == "dev-pytest"
57
+ Requires-Dist: pytest-mock==3.15.1; extra == "dev-pytest"
58
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "dev-pytest"
59
+ Requires-Dist: async-solipsism==0.9; extra == "dev-pytest"
60
+ Requires-Dist: hypothesis==6.163.0; extra == "dev-pytest"
61
61
  Provides-Extra: dev
62
62
  Requires-Dist: frequenz-core[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]; extra == "dev"
63
63
  Dynamic: license-file
@@ -200,6 +200,28 @@ class ApiClient:
200
200
  client = ApiClient.create("my-api-key") # ✅ Works
201
201
  ```
202
202
 
203
+ Annotate floating point values honestly, as Python's numeric tower lets `int`
204
+ values through any `float` annotation:
205
+
206
+ ```python
207
+ from typing import assert_never
208
+
209
+ from frequenz.core.typing import FloatInt
210
+
211
+ def describe(value: FloatInt | None) -> str:
212
+ match value:
213
+ case float() | int():
214
+ return f"number {value}"
215
+ case None:
216
+ return "nothing"
217
+ case unexpected:
218
+ assert_never(unexpected)
219
+
220
+ assert describe(1) == "number 1" # ✅ `case float():` alone would crash here
221
+ assert describe(1.5) == "number 1.5"
222
+ assert describe(None) == "nothing"
223
+ ```
224
+
203
225
  ### Strongly-Typed IDs
204
226
 
205
227
  Create type-safe identifiers for different entities:
@@ -16,4 +16,6 @@ src/frequenz_core.egg-info/PKG-INFO
16
16
  src/frequenz_core.egg-info/SOURCES.txt
17
17
  src/frequenz_core.egg-info/dependency_links.txt
18
18
  src/frequenz_core.egg-info/requires.txt
19
+ src/frequenz_core.egg-info/scm_file_list.json
20
+ src/frequenz_core.egg-info/scm_version.json
19
21
  src/frequenz_core.egg-info/top_level.txt
@@ -0,0 +1,49 @@
1
+ typing-extensions<5,>=4.13.0
2
+
3
+ [dev]
4
+ frequenz-core[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]
5
+
6
+ [dev-flake8]
7
+ flake8==7.3.0
8
+ flake8-datetimez==20.10.0
9
+ flake8-docstrings==1.7.0
10
+ flake8-pyproject==1.2.4
11
+ pydoclint==0.9.1
12
+ pydocstyle==6.3.0
13
+
14
+ [dev-formatting]
15
+ black==26.5.1
16
+ isort==8.0.1
17
+
18
+ [dev-mkdocs]
19
+ Markdown==3.10.2
20
+ black==26.5.1
21
+ mike==2.2.0
22
+ mkdocs-gen-files==0.6.1
23
+ mkdocs-literate-nav==0.6.3
24
+ mkdocs-macros-plugin==1.5.0
25
+ mkdocs-material==9.7.7
26
+ mkdocstrings[python]==1.0.6
27
+ mkdocstrings-python==2.0.5
28
+ frequenz-repo-config[lib]==0.18.0
29
+
30
+ [dev-mypy]
31
+ mypy==2.3.0
32
+ types-Markdown==3.10.2.20260712
33
+ frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]
34
+
35
+ [dev-noxfile]
36
+ nox==2026.7.11
37
+ frequenz-repo-config[lib]==0.18.0
38
+
39
+ [dev-pylint]
40
+ frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]
41
+
42
+ [dev-pytest]
43
+ pytest==9.1.1
44
+ pylint==4.0.6
45
+ frequenz-repo-config[extra-lint-examples]==0.18.0
46
+ pytest-mock==3.15.1
47
+ pytest-asyncio==1.4.0
48
+ async-solipsism==0.9
49
+ hypothesis==6.163.0
@@ -0,0 +1,55 @@
1
+ {
2
+ "files": [
3
+ ".cookiecutter-replay.json",
4
+ ".editorconfig",
5
+ ".github/ISSUE_TEMPLATE/bug.yml",
6
+ ".github/ISSUE_TEMPLATE/config.yml",
7
+ ".github/ISSUE_TEMPLATE/feature.yml",
8
+ ".github/RELEASE_NOTES.template.md",
9
+ ".github/dependabot.yml",
10
+ ".github/keylabeler.yml",
11
+ ".github/labeler.yml",
12
+ ".github/workflows/auto-dependabot.yaml",
13
+ ".github/workflows/black-migration.yaml",
14
+ ".github/workflows/ci-pr.yaml",
15
+ ".github/workflows/ci.yaml",
16
+ ".github/workflows/dco-merge-queue.yml",
17
+ ".github/workflows/isort-migration.yaml",
18
+ ".github/workflows/labeler.yml",
19
+ ".github/workflows/release-notes-check.yml",
20
+ ".github/workflows/repo-config-migration.yaml",
21
+ ".gitignore",
22
+ "CODEOWNERS",
23
+ "CONTRIBUTING.md",
24
+ "LICENSE",
25
+ "MANIFEST.in",
26
+ "README.md",
27
+ "RELEASE_NOTES.md",
28
+ "docs/CONTRIBUTING.md",
29
+ "docs/SUMMARY.md",
30
+ "docs/_css/mkdocstrings.css",
31
+ "docs/_css/style.css",
32
+ "docs/_img/logo.png",
33
+ "docs/_overrides/main.html",
34
+ "docs/_scripts/mkdocstrings_autoapi.py",
35
+ "docs/index.md",
36
+ "mkdocs.yml",
37
+ "noxfile.py",
38
+ "pyproject.toml",
39
+ "src/frequenz/core/__init__.py",
40
+ "src/frequenz/core/conftest.py",
41
+ "src/frequenz/core/datetime.py",
42
+ "src/frequenz/core/enum.py",
43
+ "src/frequenz/core/id.py",
44
+ "src/frequenz/core/math.py",
45
+ "src/frequenz/core/module.py",
46
+ "src/frequenz/core/py.typed",
47
+ "src/frequenz/core/typing.py",
48
+ "tests/math/test_interval.py",
49
+ "tests/math/test_is_close_to_zero.py",
50
+ "tests/test_enum.py",
51
+ "tests/test_id.py",
52
+ "tests/test_module.py",
53
+ "tests/test_typing.py"
54
+ ]
55
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.4.0",
3
+ "distance": 0,
4
+ "node": "g96e40b27995efe79ab7899dea61139096d2983f6",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-17"
8
+ }
@@ -1,9 +0,0 @@
1
- # Frequenz Core Library Release Notes
2
-
3
- ## Upgrading
4
-
5
- - If you used `enum.DeprecatedMember` directly anywhere, you should probably switch to using `enum.deprecated_member` instead, which will tag the member value with the appropriate type.
6
-
7
- ## New Features
8
-
9
- - A new `enum.deprecated_member` function has been added to create deprecated enum members with proper typing.
@@ -1,48 +0,0 @@
1
- typing-extensions<5,>=4.13.0
2
-
3
- [dev]
4
- frequenz-core[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]
5
-
6
- [dev-flake8]
7
- flake8==7.3.0
8
- flake8-docstrings==1.7.0
9
- flake8-pyproject==1.2.3
10
- pydoclint==0.6.11
11
- pydocstyle==6.3.0
12
-
13
- [dev-formatting]
14
- black==25.1.0
15
- isort==6.0.1
16
-
17
- [dev-mkdocs]
18
- Markdown==3.8.2
19
- black==25.1.0
20
- mike==2.1.3
21
- mkdocs-gen-files==0.5.0
22
- mkdocs-literate-nav==0.6.2
23
- mkdocs-macros-plugin==1.3.9
24
- mkdocs-material==9.6.18
25
- mkdocstrings[python]==0.30.0
26
- mkdocstrings-python==1.18.2
27
- frequenz-repo-config[lib]==0.13.5
28
-
29
- [dev-mypy]
30
- mypy==1.17.1
31
- types-Markdown==3.8.0.20250809
32
- frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]
33
-
34
- [dev-noxfile]
35
- nox==2025.5.1
36
- frequenz-repo-config[lib]==0.13.5
37
-
38
- [dev-pylint]
39
- frequenz-core[dev-mkdocs,dev-noxfile,dev-pytest]
40
-
41
- [dev-pytest]
42
- pytest==8.4.1
43
- pylint==3.3.8
44
- frequenz-repo-config[extra-lint-examples]==0.13.5
45
- pytest-mock==3.14.1
46
- pytest-asyncio==1.1.0
47
- async-solipsism==0.8
48
- hypothesis==6.138.11
File without changes
File without changes
File without changes