| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Whenever this library annotates a dataclass field as float | Other (or plain float) and later unwraps it with match … case float():, the code has a latent AssertionError on integer inputs — despite the calling code passing mypy --strict.
See for example how it is handled in :
@dataclass(frozen=True, kw_only=True)
class MetricSample:
"""A sampled metric.
...
"""
...
value: float | AggregatedMetricValue | None
"""The value of the sampled metric."""
def as_single_value(
self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG
) -> float | None:
"""Return the value of this sample as a single value.
...
"""
match self.value:
case float() | int(): # <---------------- Without `int()` we are screwed
return self.value
case AggregatedMetricValue():
...
case None:
return None
case unexpected:
assert_never(unexpected)This is a fundamental mismatch between Python's static and runtime type systems, not a bug we can hack around cleanly. Every mitigation we have identified has meaningful drawbacks. Even a bool can be passed where a float is expected.
We could be careful and do it right in out code but this keeps being a hazard to our users if they want to access .value and pattern match directly with it.
Users using:
metric_sample = MetricSample(value=1, ...)
match metric_sample.value:
case float():
return self.value
case AggregatedMetricValue():
...
case None:
return None
case unexpected:
assert_never(unexpected)mypy rejecting this code instead of passing and getting a failed assertion at runtime because the actual value type is int, not float and int doesn't inherit from float.
Annotate the field as float | int and accept the heterogeneity
Stop lying about the annotation: acknowledge in the type system that PEP 484 lets int through, and change every affected field annotation to explicitly permit both. case float() | int(): in accessors is then no longer a "widening workaround" (Option 2) — it is the natural, exhaustive way to unwrap the union, and mypy will actively push every downstream user to handle both branches.
FloatInt: TypeAlias = float | int
@dataclass(frozen=True)
class Bounds:
lower: FloatInt | None = None
upper: Floatnt | None = None
@dataclass(frozen=True)
class MetricSample:
value: Floatnt | AggregatedMetricValue | None
def as_single_value(self) -> Floatnt | None:
match self.value:
case float() | int() as v:
return v
case AggregatedMetricValue() as agg:
return agg.avg
case None:
return None
case unknown:
assert_never(unknown) # now genuinely unreachable for numeric-tower reasonsWe prototyped the most obvious fix (runtime coercion at ingress — Option 1 below).
It works, but the measured performance impact on hot-path types made us stop and file this issue instead of landing it. This ticket documents the trap, lists the options (including "just live with it"), and asks how we want to handle it in this library.
Reduced to a self-contained example:
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Wrapper:
value: float | str
def unwrap(self) -> float:
match self.value:
case float() as f:
return f
case str() as s:
return float(s)
case unknown:
assert_never(unknown) # nominally unreachable
>>> Wrapper(value=1).unwrap() # int is assignable to float under PEP 484
Traceback (most recent call last):
...
AssertionError # but isinstance(1, float) is FalseThe stored 1 is an int; case float(): is an isinstance(_, float) check which is False; the value falls through the exhaustive-looking match to assert_never.
Any field annotated as float | Other (including float | None, float | SomeClass, etc.) that is later unwrapped by match has this shape. Widening the arm to case float() | int(): fixes int/bool but moves the same crash to Decimal, Fraction, and NumPy non-float scalars.
x: float = 1 type-checks under mypy, pyright, basedpyright, and every mainstream Python type-checker. mypy --strict does not disable this — PEP 484's numeric tower is baked into the type system. Even inside a library where every caller uses --strict, all of the following are legal and produce integer runtime values in float-annotated storage:
def load(v: int) -> None:
x: float = v # legal
Bounds(lower=x) # legal; stores int at runtime
raw: Sequence[float] = [1, 2, 3] # legal; list[int] is a Sequence[float]
AggregatedMetricValue(avg=1.0, raw=raw) # legal; raw contains ints at runtime
dataclasses.replace(bounds_instance, lower=1) # legal; dispatches through the field annotation
json.loads('{"lower": 0}') # produces int; caller may forward it as floatThere is no way to write a case arm that matches "float including its numeric-tower siblings" without spelling every accepted runtime type explicitly, which just re-creates the same problem for the next unknown type that appears.
A Protocol whose methods exist on float but not on int/bool can carve out direct int literals at construction time, but the numeric tower still lets int slip in through widened float variables, Sequence[float] covariance, dataclasses.replace(), and other paths.
Every dataclass with a float-typed field is subject to the trap. Only accessors that currently use case float(): (or its widened case float() | int(): sibling) crash today; fields without accessors are traps waiting for a future accessor addition.
Released today (v0.4.0):
| Class | Field(s) | Accessor with case? | Status |
|---|---|---|---|
| MetricSample | value: float | AggregatedMetricValue | None | as_single_value() uses widened case float() | int(): | Latent crash on Decimal / Fraction / other reals |
| AggregatedMetricValue | avg: float, min/max: float | None, raw: Sequence[float] | none | Trap if an accessor is ever added |
| Bounds | lower/upper: float | None | none | Trap if an accessor is ever added |
In-progress feature branches also add:
| Class | Field(s) | Accessor with case? |
|---|---|---|
| Location | latitude/longitude: float | Invalid* | get_latitude() / get_longitude() use case float(): → crashes today on int inputs |
| InvalidLatitude, InvalidLongitude | value: float | none |
| PowerTransformer | primary/secondary_voltage: float | none |
MetricSample, AggregatedMetricValue, and Bounds are constructed many times per second per component in typical Frequenz deployments — any per-construction cost on those types is measurable in production.
We considered many options before arriving to the proposed solution (option 6). They are preserved here for documentation.
All considered alternative optionsWe prototyped this approach on a working branch across every class listed above. It adds __post_init__ to each frozen dataclass and normalizes every float-typed field to a real float via object.__setattr__:
def __post_init__(self) -> None:
object.__setattr__(self, "avg", float(self.avg))
if self.min is not None:
object.__setattr__(self, "min", float(self.min))
if self.max is not None:
object.__setattr__(self, "max", float(self.max))
object.__setattr__(self, "raw", [float(x) for x in self.raw])The prototype passes nox (pytest with warnings-as-errors, mypy --strict, pylint 10.00/10). Functionally the approach is correct. What stopped us from landing it was the measured performance impact on hot-path types, which is the reason for filing this issue rather than merging the prototype.
Pros
Cons — the reason this issue exists
# before the prototype 500000 loops, best of 5: 895 nsec per loop # after the prototype 100000 loops, best of 5: 2.06 usec per loop
Narrowest possible change:
match self.value:
case float() | int() as v:
return float(v)
...Pros
Cons
Define a public Float protocol whose methods are on float but not on int/bool, and use it as the constructor parameter type (storage annotation stays float):
from typing import Protocol
class Float(Protocol):
"""Structural stand-in for `float` that excludes `int` and `bool`.
Python's numeric tower makes `int` and `bool` assignable to `float`
under PEP 484, which breaks `match … case float():` at runtime.
This protocol carves out just the exact-`float` values by matching
methods present on `float` but not on `int` / `bool`.
"""
def hex(self) -> str: ...
def as_integer_ratio(self) -> tuple[int, int]: ...
def is_integer(self) -> bool: ...
@dataclass(frozen=True, slots=True, init=False)
class Bounds:
lower: float | None
upper: float | None
def __init__(self, lower: Float | None = None, upper: Float | None = None) -> None:
object.__setattr__(self, "lower", lower)
object.__setattr__(self, "upper", upper)Pros
Cons — this does not actually close the runtime hole
x: float = 1 # legal under mypy --strict
Bounds(lower=x) # accepted; runtime stores intraw: Sequence[float] = [1, 2, 3]
AggregatedMetricValue(avg=1.0, raw=raw) # accepted; ints stored in rawThe Protocol closes only the direct-literal case. Every non-trivial ingress path still requires runtime coercion to be safe.
Keep the runtime invariant, but skip the float(x) work when the value is already exactly float:
def __post_init__(self) -> None:
if type(self.avg) is not float:
object.__setattr__(self, "avg", float(self.avg))
...Pros
Cons
Do not touch the affected classes. Instead:
Pros
Cons
What is the invariant?
Every other decision follows from this choice.
Benchmark reality. Options 1 and 4 need real numbers on Python 3.11 and 3.14 with realistic mixed inputs — already-float from protobuf, int from user code, various raw sizes — before committing. The prototype benchmark above is only on int inputs for one class.
raw element policy. If the invariant is "always float", we pay O(n) per construction to verify or coerce every element. If not, we accept aliasing and require callers to hand us a fresh list[float].
NumPy contract. Are numpy.float32 / float16 scalars in scope for our library? If yes, both structural Protocol and match-based accessors need explicit tests.
bool policy. Under Option 1/4, Bounds(lower=True) stores 1.0. Under Options 2/5 it leaks through as True. Under Option 3 it's a type error. Pick one and document it.
Stop lying about the annotation: acknowledge in the type system that PEP 484 lets int through, and change every affected field annotation to explicitly permit both. case float() | int(): in accessors is then no longer a "widening workaround" (Option 2) — it is the natural, exhaustive way to unwrap the union, and mypy will actively push every downstream user to handle both branches.
@dataclass(frozen=True)
class Bounds:
lower: float | int | None = None
upper: float | int | None = None
@dataclass(frozen=True)
class MetricSample:
value: float | int | AggregatedMetricValue | None
def as_single_value(self) -> float | int | None:
match self.value:
case float() | int() as v:
return v
case AggregatedMetricValue() as agg:
return agg.avg
case None:
return None
case unknown:
assert_never(unknown) # now genuinely unreachable for numeric-tower reasonsPros
Cons
Priority
| Back | FazBrowse Home | New Git URL |
Activity
llucax commented on Jul 15, 2026
I've been thinking about this, and this really looks like a Python/mypy bug to me. Even using mypy --enable-error-code=exhaustive-match it says the match block is covered exhaustively but then it asserts on assert_never(). This makes no sense. I will see if there is already a bug report about this and if not I will create one, although I don't have very high hopes of it being solved, as this behavior is very intentional, but I think it predates the match expression, but a lot of code probably relies on it so I think it might be very hard to change it.
llucax commented on Jul 15, 2026
OK, this is a well-know, long-standing issue, and there are more cases where it breaks besides match, like:
This happily works in with mypy but it raises an exception at runtime because int doesn't have the hex method 🤦
There is a proposal to make float mean float | int, but the PR has no activity since Nov 2025, so again, I don't have many hopes this gets in soon.
The good news is pyright already does this (considers float to actually be float | int) so the match case is detected by pyright:
This is very likely why we have the exception in one place correctly handled (probably detected by the code editor using pyright as LSP). But even then, it seems it only applies it as a heuristic in certain situations, like assignments, but not always, so the g.hex() is still not properly handled by pyright either.
So, I think I'm now included for a option 6: explicitly type float as float | int.
Option 6 — Annotate the field as float | int and accept the heterogeneity
Stop lying about the annotation: acknowledge in the type system that PEP 484 lets int through, and change every affected field annotation to explicitly permit both. case float() | int(): in accessors is then no longer a "widening workaround" (Option 2) — it is the natural, exhaustive way to unwrap the union, and mypy will actively push every downstream user to handle both branches.
Pros
Cons
Add `FloatInt` type alias
llucax commented on Jul 22, 2026
Some news about this:
llucax commented on Jul 22, 2026
I updated the issue text to indicate a solution was already picked, and put all options in a "details" block to avoid having a huge issue.
Add `FloatInt` type alias
Make all `float` annotations `float | int` (frequenz-floss#256)