Open/Closed Intervals in Python Using Chained Comparisons

Check this out:

>>> T = Annotated[float, 1 < limits.range]
>>> T.__metadata__
(from (1, inf],)
>>> [x for x in 4 < b.range.step(0.5) <= 6] 
[4.5, 5.0, 5.5, 6.0]
# Why

Python 3.9 added support for typing.Annotated, which enables arbitrary loading of metadata onto a variable through type hinting. The intended use case is for libraries like FastAPI to impose additional restrictions on parameters that can be caught by static analysis that are frustrating to implement using type definitions alone.

One of the examples in the documentation describes how one might use this to restrict the valid input range of a query:

@dataclass
class ValueRange:
    lo: int
    hi: int

T1 = Annotated[int, ValueRange(-10, 5)]

Recently, I found myself needing to implement something similar, but for continuous variables. Consider the function y=log(x-1). The domain of the function is all x > 1, sometimes expressed in the format (1, ∞], but how would you describe this using the ValueRange class? You’d need to augment the class definition with additional specifiers indicating whether the interval bounds are open or closed. That’s four input parameters expressed in a format prone to errors. Is there a way to express the same information in a more understandable fashion?

“Hey wait a second,” I thought, “Python allows comparison operators to be chained, why not express the interval like 1 < x <= inf? That’s way more intuitive than using ValueRange.

Two problems: first, don’t comparators return boolean True/False when evaluated? How would the interval bounds get stored in the type annotation if it’s done by value? Second, in what way can the center expression of x be written that results in valid Python syntax?

How

Let me show the finished class definition first to make things easier to explain:

class limits:
    @classmethod
    @property
    # class properties are deprecated in Python 3.13+
    def range(cls):
        return cls()
    
    def __init__(self):
        self.lower = float('-inf')
        self.lower_open = True
        self.upper = float('inf')
        self.upper_open = True

    def __lt__(self, other):
        self.upper = other
        self.upper_open = False
        return self

    def __le__(self, other):
        self.upper = other
        self.upper_open = True
        return self
    
    def __gt__(self, other):
        self.lower = other
        self.lower_open = False
        return self

    def __ge__(self, other):
        self.lower = other
        self.lower_open = True
        return self
    
    def __repr__(self):
        return f'from {"[" if self.lower_open else "("}{self.lower}, {self.upper}{"]" if self.upper_open else ")"}'

Huh

There’s two things of importance going on here. First, the overridden comparison operator dunder methods all return self instead of True/False. Second, a class property limits.range was added which returns a distinct instantiation of limits() when called. Because chained comparisons only make one call to limits.range and share the instaniation, both the lower and upper bounds can be updated on the same instance of limits. Combined, the annotation shockingly works more or less as envisioned:

>>> 1 < limits.range <= 5
from (1, 5]
>>> 1 <= limits.range 
from [1, inf]
>>> 1 <= limits.range < 5  
from [1, 5)

SIDE NOTE

Class properties were introduced in Python 3.9, and apparently caused enough headache that they were yeeted out of the interpreter in 3.13. This StackOverflow answer shows one possible workaround, which works but is somewhat verbose so I didn’t use it here. Additionally, the Python release notes claim:

To “pass-through” a classmethod, consider using the __wrapped__ attribute that was added in Python 3.10.

Which doesn’t make a lick of sense at all. If anybody knows how to implement class properties using this method, I’d really like to know.

There’s one bit of info still missing: how is the chained comparison returning a limits object and not True/False? I think this snippet should be illuminating:

>>> 6 and 7
7

Apparently, the short-circuiting behavior of and in Python results in behavior equivalent to B if A else A. Seems like this extends to truthy/falsy values as well.

By the way, while this is all legal Python, the code stinks to high hell. Between the comparison overrides modifying the internal state of the object, the class property returning an instantiation of the class, and the short-circuiting behavior that seems more like luck than anything else, it’s a wonder that the code works like intended.

Whee

At this point I was feeling pretty good about the code, but then I was struck by another bolt of inspiration. Since I’ve effectively created syntax to define a numerical range, doesn’t that mean I can also loop through values in the defined interval? Well…

class b(limits):
    def __init__(self):
        super().__init__()
        self._step = 1

    def step(self, value):
        self._step = value
        return self

    def __iter__(self):
        self._value = self.lower + (0 if self.lower_open else self._step)
        return self
    
    def __next__(self):
        if self._value > self.upper or (self._value == self.upper and not self.upper_open):
            raise StopIteration
        value = self._value
        self._value += self._step
        return value
>>> [x for x in 4 < b.range.step(0.5) <= 6] 
[4.5, 5.0, 5.5, 6.0]

I can’t believe this works either. As much as I would have liked to chop down the looping syntax down to the bone to something like for 4 < x <= 6, that doesn’t seem possible to accomplish with current for loop Python grammar. But maybe you can come up with something?

This article was updated on May 28, 2026