Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
python:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
nox-session:
# To speed things up a bit we use the special ci_checks_max session
# that uses the same venv to run multiple linting sessions
Expand Down Expand Up @@ -115,6 +117,8 @@ jobs:
python:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
runs-on: ${{ matrix.platform }}

steps:
Expand Down
81 changes: 76 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

Core utilities to complement Python's standard library. This library provides
essential building blocks for Python applications, including mathematical
utilities, datetime constants, typing helpers, strongly-typed identifiers, and
module introspection tools.
utilities, datetime constants, typing helpers, strongly-typed identifiers,
module introspection tools, and warning and deprecation helpers.

The `frequenz-core` library is designed to be lightweight, type-safe, and
follow modern Python best practices. It fills common gaps in the standard
Expand All @@ -19,8 +19,8 @@ library with utilities that are frequently needed across different projects.

The following platforms are officially supported (tested):

- **Python:** 3.11
- **Operating System:** Ubuntu Linux 20.04
- **Python:** 3.11, 3.12, 3.13, 3.14
- **Operating System:** Ubuntu Linux 24.04
- **Architectures:** amd64, arm64

## Installation
Expand Down Expand Up @@ -92,7 +92,7 @@ positive = Interval(0, None) # [0, ∞]
assert 1000 in positive # True
```

### `Enum` with deprecated members
### `Enum` Utilities

Define enums with deprecated members that raise deprecation warnings when
accessed:
Expand Down Expand Up @@ -158,6 +158,77 @@ assert describe(1.5) == "number 1.5"
assert describe(None) == "nothing"
```

### Warnings/Deprecations Utilities

Silence a warning around a call without the side effect
[`warnings.catch_warnings`](https://docs.python.org/3/library/warnings.html#warnings.catch_warnings)
has, which is to reset the deduplication history of the whole program, so
every warning already shown is shown again
([python/cpython#73858](https://github.com/python/cpython/issues/73858)):

```python
import warnings

from frequenz.core.warnings import ignoring_deprecations

def legacy_parse(raw: str) -> int:
warnings.warn("legacy_parse() is deprecated", DeprecationWarning, stacklevel=2)
return int(raw)

def parse(raw: str) -> int:
# Only around the call that reaches the deprecated symbol.
with ignoring_deprecations():
return legacy_parse(raw)

with warnings.catch_warnings(record=True, action="default") as shown:
for _ in range(10):
warnings.warn("said once", UserWarning)
parse("1")

assert len(shown) == 1 # ✅ `catch_warnings()` in `parse()` would show it 10 times
```

Keep the old import path of a symbol that moved working, serving the very same
object so `isinstance` keeps working through both paths:

```python
from typing import TYPE_CHECKING, TypeAlias

from frequenz.core.warnings import deprecated_aliases

if TYPE_CHECKING:
# Type checkers can't see the runtime `__getattr__` in the `else` branch.
from decimal import Decimal as _Decimal
from fractions import Fraction as _Fraction

Decimal: TypeAlias = _Decimal
Rational: TypeAlias = _Fraction
else:
# In the `else`, never at module level: a `__getattr__` mypy can see makes
# every name it doesn't know in this module `Any`, typos in downstream
# imports included.
__getattr__ = deprecated_aliases(
__name__,
{
"Decimal": "decimal", # decimal.Decimal
"Rational": "fractions:Fraction", # Renamed on the way out
},
)
```

And check in a test that a piece of code doesn't warn, without the `"error"`
filter that would make `warnings.warn()` raise inside the code under test:

```python
from frequenz.core.warnings import asserting_no_deprecations

def parse(raw: str) -> int:
return int(raw)

with asserting_no_deprecations():
assert parse("1") == 1
```

### Strongly-Typed IDs

Create type-safe identifiers for different entities:
Expand Down
16 changes: 5 additions & 11 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
# Frequenz Core Library Release Notes

## Summary

<!-- Here goes a general summary of what this release is about -->

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
A new `frequenz.core.warnings` module with:

- `ignoring_warnings()` and `ignoring_deprecations()` to silence warnings/deprecations around a piece of code. Unlike `warnings.catch_warnings()`, entering and leaving it doesn't reset the warnings deduplication history of the program, so warnings already shown are not shown again, working around [python/cpython#73858](https://github.com/python/cpython/issues/73858). It costs about the same as the standard library block, so it is also usable on a hot path, where repairing the damage afterwards would not be.

## Bug Fixes
- `asserting_no_warnings()`, and its `asserting_no_deprecations()` shortcut, fail when the code in the block raises a matching warning/deprecation, listing each one with the place it came from. They are meant for tests, and are a better tool than an `"error"` filter, which makes `warnings.warn()` raise inside the code under test and so changes the very behaviour the test is checking.

<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
- `deprecated_aliases()` builds a module `__getattr__` that warns when a symbol that moved to another module is reached through its old import path, serving the very same object so `isinstance` keeps working through both paths.
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ disable = [
]

[tool.pytest.ini_options]
addopts = "-W=all -Werror -Wdefault::DeprecationWarning -Wdefault::PendingDeprecationWarning -vv"
filterwarnings = [
"error",
"once::DeprecationWarning",
"once::PendingDeprecationWarning",
]
testpaths = ["tests", "src"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
Expand Down
68 changes: 68 additions & 0 deletions src/frequenz/core/warnings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Warnings utilities.

This module provides [`ignoring_warnings`][.ignoring_warnings], a context manager to
silence warnings around a piece of code, without the side effect that
[`warnings.catch_warnings`][] has, and
[`ignoring_deprecations`][.ignoring_deprecations] for the common case of a library
having to touch a symbol it deprecated itself.

It also provides [`deprecated_aliases`][.deprecated_aliases], to keep the old import
path of a symbol that moved to another module working, warning whoever uses it, and
[`asserting_no_warnings`][.asserting_no_warnings] with its
[`asserting_no_deprecations`][.asserting_no_deprecations] shortcut, to check in a test
that a piece of code doesn't warn.

The documented way of silencing a warning locally is a
[`warnings.catch_warnings`][] block, but merely entering and leaving one invalidates
the warnings deduplication history of the whole program, so every warning that was
already shown is shown again, and again on every call. This is
[python/cpython#73858](https://github.com/python/cpython/issues/73858), open since
2017.

[`ignoring_warnings`][.ignoring_warnings] adds the filter to the list the interpreter
consults and takes it out again on exit, which leaves the deduplication history
untouched, for about half a microsecond more per block than the standard library one
costs:

```python
import warnings

from frequenz.core.warnings import ignoring_warnings


def legacy_convert(value: str) -> int:
warnings.warn("legacy_convert() is deprecated", DeprecationWarning, stacklevel=2)
return int(value)


def convert(value: str) -> int:
with ignoring_warnings(category=DeprecationWarning):
return legacy_convert(value)


with warnings.catch_warnings(record=True, action="default") as caught:
for _ in range(10):
warnings.warn("shown only once", UserWarning)
convert("1")

assert len(caught) == 1
```

With [`warnings.catch_warnings`][] in `convert()` the same loop shows the
`UserWarning` ten times.
"""

from ._asserting import asserting_no_deprecations, asserting_no_warnings
from ._deprecated_aliases import deprecated_aliases
from ._ignoring import ignoring_deprecations, ignoring_warnings

__all__ = [
"asserting_no_deprecations",
"asserting_no_warnings",
"deprecated_aliases",
"ignoring_deprecations",
"ignoring_warnings",
]
Loading