Skip to content
17 changes: 17 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@

Migrate to `bounds_set` (or `get_bounds_set()`) for all of these.

* `frequenz.client.common.proto.datetime_from_proto` is now deprecated; use `datetime_from_proto2` instead.

* `frequenz.client.common.metrics.MetricSample.sample_time` is now a deprecated read-only property; use the new `sample_time2` field instead.

The field became `datetime | InvalidDatetime`, which the released `datetime` annotation cannot express, so it was renamed. Reading `sample_time` still returns a `datetime` and now emits a `DeprecationWarning`; for a malformed wire timestamp it raises `InvalidDatetimeError` (a `ValueError`) rather than returning a repaired value. `get_sample_time()` does the same without the warning.

Constructing with `sample_time=` is **not** deprecated and keeps working: it accepts a well-formed `datetime` today and will accept the wider type once `sample_time2` is renamed back to `sample_time`. Use `sample_time2=` to build a sample from a malformed wire timestamp.

Because `sample_time` is no longer a real field, `dataclasses.fields()`, `asdict()`, `astuple()` and `replace()` see `sample_time2`.

* `frequenz.client.common.metrics.proto.v1alpha8.metric_sample_from_proto_with_issues` no longer drops invalid bounds or reports them as a major issue.

Malformed bounds are now preserved in the returned `MetricSample.bounds_set` as an `InvalidBoundsSet` (validity is encoded in the type), so the previous "bounds for ... is invalid, ignoring these bounds" major issue is no longer produced.
Expand Down Expand Up @@ -136,8 +146,15 @@
* `frequenz.client.common.metrics.MetricConnection.get_category()`
* `frequenz.client.common.metrics.MetricSample.get_metric()`
* `frequenz.client.common.metrics.MetricSample.get_bounds_set()`
* `frequenz.client.common.metrics.MetricSample.get_sample_time()`
* `frequenz.client.common.microgrid.electrical_components.ElectricalComponent.get_metric_config_bounds()`
Comment on lines +149 to 150

* Added `frequenz.client.common.InvalidDatetime`, a protobuf-independent wrapper preserving the raw `seconds` and `nanos` of a wire timestamp that is not a well-formed protobuf `Timestamp`, and `frequenz.client.common.InvalidDatetimeError`, raised by the safe accessors for those fields. Both are exported from the top-level package, not from `frequenz.client.common.types`, because a timestamp is not a `frequenz-api-common` message. See the Upgrading section for the fields that can now hold one.

* Added `frequenz.client.common.metrics.MetricSample.sample_time2`, typed `datetime | InvalidDatetime`, replacing the now-deprecated `sample_time` property (see Upgrading).

* Added `frequenz.client.common.proto.datetime_from_proto2` returning `datetime | InvalidDatetime`. This is the replacement for the now-deprecated `datetime_from_proto`.

* Added new delivery-area class hierarchy:

* `frequenz.client.common.grid.BaseDeliveryArea` — abstract common supertype of the two concrete leaves; not directly instantiable.
Expand Down
12 changes: 9 additions & 3 deletions docs/user-guide/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ The library groups its wrappers by the kind of common API data you receive.
Use this map to find the relevant domain, then follow its links for the public
API details.

## Utilities

[`InvalidDatetime`][frequenz.client.common.InvalidDatetime] is not part of
`frequenz-api-common` so it lives in the top-level package. It preserves a wire
timestamp that is not well-formed and can appear in any wrapper where a
[`datetime`][datetime.datetime] is expected.

## Grid

Start with [`DeliveryArea`][frequenz.client.common.grid.DeliveryArea] and
Expand Down Expand Up @@ -37,9 +44,8 @@ Use [`Microgrid`][frequenz.client.common.microgrid.Microgrid],
[`MicrogridId`][frequenz.client.common.microgrid.MicrogridId],
[`EnterpriseId`][frequenz.client.common.microgrid.EnterpriseId], and
[`Lifetime`][frequenz.client.common.microgrid.Lifetime].
[`BaseLifetime`][frequenz.client.common.microgrid.BaseLifetime] and
[`InvalidLifetime`][frequenz.client.common.microgrid.InvalidLifetime] represent
the lifetime variants, while
[`InvalidLifetime`][frequenz.client.common.microgrid.InvalidLifetime] carries
malformed wire data, while
[`InvalidLifetimeError`][frequenz.client.common.microgrid.InvalidLifetimeError]
is raised by safe accessors.

Expand Down
6 changes: 6 additions & 0 deletions docs/user-guide/reading-string-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ and [`InvalidCountryCode`][frequenz.client.common.types.InvalidCountryCode]
use the same marker. See [validity in the type](validity-in-the-type.md) to
handle these values.

[`InvalidDatetime`][frequenz.client.common.InvalidDatetime] prints both raw
numbers of a malformed wire timestamp inside the marker, as
`<invalid:253402300800s+0ns>`. The nanosecond part always carries its sign,
because a fraction outside `[0, 999999999]` is one of the two reasons the
timestamp is there at all.

An unexpected raw number without `<invalid:…>` means something different: the
data is well-formed, but this client version does not recognize it yet. For
example, [`UnrecognizedElectricalComponent`][frequenz.client.common.microgrid.electrical_components.UnrecognizedElectricalComponent]
Expand Down
42 changes: 42 additions & 0 deletions docs/user-guide/validity-in-the-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,48 @@ latitude or raises
[`InvalidLatitudeError`][frequenz.client.common.types.InvalidLatitudeError],
whose [`InvalidLatitudeError.value`][frequenz.client.common.types.InvalidLatitudeError.value] is the raw value.

Timestamps work the same way. A protobuf `Timestamp` counts seconds and
nanoseconds as plain integers, so a message can carry values the `Timestamp`
contract does not allow: an instant outside the years 1 to 9999, or a
nanosecond fraction outside `[0, 999999999]`. Every wrapper timestamp field is
therefore `datetime | `[`InvalidDatetime`][frequenz.client.common.InvalidDatetime],
with [`InvalidDatetime.seconds`][frequenz.client.common.InvalidDatetime.seconds]
and [`InvalidDatetime.nanos`][frequenz.client.common.InvalidDatetime.nanos]
holding exactly what the server sent:

```python
from datetime import datetime

from frequenz.client.common.metrics import BoundsSet, Metric, MetricSample
from frequenz.client.common import InvalidDatetime, InvalidDatetimeError

sample = MetricSample(
sample_time2=InvalidDatetime(seconds=253402300800, nanos=0),
metric=Metric.AC_POWER_ACTIVE,
value=42.0,
bounds_set=BoundsSet(),
)

match sample.sample_time2:
case InvalidDatetime(seconds=raw_seconds):
print(raw_seconds) # 253402300800
case datetime() as sample_time:
print(sample_time.isoformat())

try:
sample.get_sample_time()
except InvalidDatetimeError as error:
print(error.attr_name) # sample_time2
print(error.datetime) # <invalid:253402300800s+0ns>
```

A [`Lifetime`][frequenz.client.common.microgrid.Lifetime] is the exception: its
two ends are plain `datetime | None`, because a period whose ends cannot be
ordered is not a usable period. Only
[`InvalidLifetime`][frequenz.client.common.microgrid.InvalidLifetime] can hold
an [`InvalidDatetime`][frequenz.client.common.InvalidDatetime], and a malformed
wire timestamp gives you one of those instead.

Some wrapper types use a dedicated subclass for a value that is invalid,
unspecified, or unrecognized. For a battery, that can be
[`UnspecifiedBattery`][frequenz.client.common.microgrid.electrical_components.UnspecifiedBattery]
Expand Down
19 changes: 19 additions & 0 deletions docs/wrapping-guide/validity-in-the-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ For example,
[`InvalidDeliveryArea`][frequenz.client.common.grid.InvalidDeliveryArea] share
a base while making their validity visible in annotations.

Share a base only while both types hold the same field types. When the invalid
type has to accept a wider type in a field, because that field can itself
carry an `Invalid*` wrapper, write two independent classes instead.
[`BoundsSet`][frequenz.client.common.metrics.BoundsSet] and
[`InvalidBoundsSet`][frequenz.client.common.metrics.InvalidBoundsSet] do this,
as do [`Lifetime`][frequenz.client.common.microgrid.Lifetime] and
[`InvalidLifetime`][frequenz.client.common.microgrid.InvalidLifetime].

Normal constructors enforce the valid subclass's rules. A conversion function
that sees invalid protobuf data creates the matching invalid subclass and
returns `X | InvalidX`. The invalid subclass keeps the raw fields for
Expand Down Expand Up @@ -50,6 +58,17 @@ with [`InvalidLatitude`][frequenz.client.common.types.InvalidLatitude],
[`InvalidCountryCode`][frequenz.client.common.types.InvalidCountryCode]. Each
wrapper keeps the raw value while leaving the other fields usable.

Reuse an existing field wrapper when several types have the same field. Every
wrapper timestamp is `datetime | InvalidDatetime` because a protobuf
`Timestamp` can break its own contract no matter which message it arrives in.
One wrapper and one
[`InvalidDatetimeError`][frequenz.client.common.InvalidDatetimeError] mean a
caller learns the pattern once. A field wrapper is protobuf-independent, so it
belongs in a public type module, not next to the conversion function that
produces it. When, it wraps no `frequenz-api-common` message at all, like a
timestamp, put it directly in the top-level package or an utility-specific
module, do not mix it with a domain-specific module.
Comment on lines +68 to +70

## Represent protobuf recovery as a subtype

When the class identifies a protobuf category or type, use dedicated subclasses
Expand Down
3 changes: 3 additions & 0 deletions src/frequenz/client/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

"""Common code and utilities for Frequenz API clients."""

from ._datetime import InvalidDatetime, InvalidDatetimeError
from ._exception import (
ClientCommonError,
InvalidAttributeError,
Expand All @@ -14,6 +15,8 @@
__all__ = [
"ClientCommonError",
"InvalidAttributeError",
"InvalidDatetime",
"InvalidDatetimeError",
"MissingFieldError",
"UnrecognizedEnumValueError",
"UnspecifiedEnumValueError",
Expand Down
82 changes: 82 additions & 0 deletions src/frequenz/client/common/_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Timestamps that have no Python equivalent."""

from dataclasses import dataclass

from ._exception import InvalidAttributeError


@dataclass(frozen=True, kw_only=True)
class InvalidDatetime:
"""A wire timestamp with no [`datetime`][datetime.datetime] equivalent.

A protobuf `Timestamp` counts whole [`seconds`][.seconds] since the Unix
epoch plus a fraction in [`nanos`][.nanos]. Both are plain integers on the
wire, so a decoded message can carry a value with no meaningful
[`datetime`][datetime.datetime]:

- a [`seconds`][.seconds] count outside the years 1 to 9999, which is both
the range the protobuf specification allows and the range
[`datetime`][datetime.datetime] covers;
- a [`nanos`][.nanos] fraction outside `[0, 999999999]`, which the
protobuf specification does not allow and whose intended meaning is
therefore unknown.

This wrapper keeps both raw numbers unchanged so callers can inspect,
report, or reinterpret what the server sent, instead of losing the message
to an exception or to a silently repaired value. It is
protobuf-independent, so it can appear in public wrapper fields.
"""

seconds: int
"""The raw number of seconds since 1970-01-01T00:00:00Z."""

nanos: int
"""The raw fraction of a second, in nanoseconds, to add to [`seconds`][..seconds]."""

def __str__(self) -> str:
"""Return a compact representation flagging this as an invalid value."""
return f"<invalid:{self.seconds}s{self.nanos:+d}ns>"


class InvalidDatetimeError(InvalidAttributeError):
"""Raised when a semantic accessor sees a timestamp with no `datetime` equivalent.

The offending [`InvalidDatetime`][..InvalidDatetime] is available as the
[`datetime`][.datetime] attribute so callers can inspect the raw wire
data.

This is also a [`ValueError`][] for convenience.
"""

def __init__(
self,
instance: object,
attr_name: str,
datetime: InvalidDatetime,
message: str | None = None,
) -> None:
"""Initialize this error.

Args:
instance: The instance that was being accessed when this error was raised.
attr_name: The name of the attribute that was being accessed.
datetime: The invalid timestamp instance.
message: A custom error message. If `None`, a default message
mentioning the invalid timestamp is used.
"""
self.datetime: InvalidDatetime = datetime
"""The invalid timestamp that caused this error."""

super().__init__(
instance,
attr_name,
(
message
if message is not None
else f"invalid timestamp {datetime} for attribute {attr_name!r} "
f"in {instance}"
),
)
Loading