Library / SDK
AlexandreDecan/portion avatar
AlexandreDecan/portion

portion: a Python library for intervals, interval sets and the operations on them

portion, a Python library providing data structure and operations for intervals.

522 stars41 forksPythonLGPL-3.0

At a glance

What is it?
portion models intervals over any comparable objects, supports open, closed, semi-infinite and interval sets, with automatic simplification of results. It is a small library with one runtime dependency, written by a self-described stubborn but enthusiastic Pythonista and covered by a large test suite.
Who is it for?
Adopt portion if your problem is genuinely about ranges over comparable values, whether that is time windows, numeric bands, version ranges or string spans, because it gives you closed and open bounds, union and intersection with automatic simplification, and an interval to data mapping in one place. Do not adopt it as a general range type for a single domain where the standard library already fits, since it is a general library rather than a domain one.
Can I use it commercially?
Yes, with conditions. LGPL-3.0 is a weak copyleft licence: you can use it inside commercial and closed-source software, but if you distribute changes to its own files, you must publish those changes under the same licence.
Is it still maintained?
Yes. The repository last received commits 23 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The scope: intervals over any comparable objects

The portion library provides a data structure and operations for intervals in Python, and the feature list in the README is precise enough to be worth reading as a specification.

Support intervals of any comparable objects is the first item, and it is the one that decides whether the library is useful to you. The bounds do not have to be numbers. They can be strings, dates, or anything else that supports comparison:

python
>>> P.closed(1.2, 2.4)
[1.2,2.4]
>>> P.closed('a', 'z')
['a','z']
>>> import datetime
>>> P.closed(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10))
[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)]

Closed or open, finite or semi-infinite intervals are supported. Interval sets, meaning a union of atomic intervals, are supported. Automatic simplification of intervals is supported. And then the operation list: comparison, transformation, intersection, union, complement, difference and containment, plus tests for emptiness, atomicity, overlap and adjacency, discrete iteration over the values of an interval, a dict-like structure to map intervals to data, and import and export of intervals to strings and to Python built-in data types.

Two claims in the list are less common in a library description. Heavily tested with high code coverage, regardless of what it means, is a self-aware way of saying the test suite is large and the coverage number is high. And mainly developed by a stubborn but enthusiastic Pythonista, which is the author telling you the project has one dominant maintainer.

The project is LGPL-3.0-or-later according to the project metadata, with the licence text in LICENSE.txt, the author is Alexandre Decan, and the primary language is Python. The recent releases are 2.6.3 on 2026-09-29, 2.6.2, the Do it faster! release, on 2026-06-14 and 2.6.1, the pyproject.toml release, on 2025-05-25. The version in the project file is 2.7.0-pre1, so there is a pre-release in progress alongside the published 2.6.x line.

Creating intervals: four bound combinations and the two empty cases

The construction helpers mirror the mathematical notation, and there are six of them:

python
>>> P.open(1, 2)
(1,2)
>>> P.closed(1, 2)
[1,2]
>>> P.openclosed(1, 2)
(1,2]
>>> P.closedopen(1, 2)
[1,2)
>>> P.singleton(1)
[1]
>>> P.empty()
()

The repr is the standard one, a parenthesis for an open bound and a square bracket for a closed one, which means an interval prints the way it would be written in a textbook. A singleton is a closed interval with equal bounds, and the empty interval has its own distinct repr.

Infinite and semi-infinite intervals use P.inf and -P.inf as bounds, and these two objects support comparison with any other object:

python
>>> P.inf > 'a', P.inf > 0, P.inf > True
(True, True, True)
>>> P.openclosed(-P.inf, 0)
(-inf,0]
>>> P.closed(-P.inf, P.inf)  # Automatically converted to an open interval
(-inf,+inf)

That last line is the important one. When an infinity is used as a bound, the corresponding boundary is automatically converted to an open one, so the whole real line is the open interval from minus infinity to plus infinity rather than a closed one. The reason is mathematical rather than cosmetic: there is no maximum value, so the endpoint is not attained, and representing it as closed would be a lie that shows up later in a containment check.

The infinite objects compare successfully against values of different types, which is what lets P.inf be used as a bound for strings and dates in the same library without special-casing the comparison operators.

Interval sets, iteration, and indexing into a sorted structure

An Interval instance is described as a disjunction of atomic intervals, each representing a single interval, and it can be iterated to access the underlying atomic intervals sorted by their lower and upper bounds.

python
>>> list(P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))
[[0,1], (10,11), [20,21]]
>>> list(P.empty())
[]

The pipe operator is the union, and the result comes back sorted regardless of the order you wrote the parts in. The same structure supports indexing and slicing, and the README describes these as nested sorted intervals retrieved with a position or a slice:

python
>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[0]
[0,1]
>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[-2]
(10,11)
>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[:2]
[0,1] | (10,11)

So an Interval is not only a set of values, it is a sequence of components you can index into, and a slice returns another interval rather than a list. Negative indices work, and the result of a slice is itself an interval, so the operations compose.

The sorting is not cosmetic. If the components were not kept in order, containment and overlap checks would need to scan the whole set. Keeping the parts sorted at all times is what makes the structure behave like an ordered collection rather than a bag.

The empty interval iterates to an empty list, which is the consistent behaviour: an interval with no components has nothing to yield.

Automatic simplification, and the discrete case that is deliberately not simplified

For convenience, intervals are automatically simplified, and the examples show how much of the work the library does for you:

python
>>> P.closed(0, 2) | P.closed(2, 4)
[0,4]
>>> P.closed(1, 2) | P.closed(3, 4) | P.closed(2, 3)
[1,4]
>>> P.empty() | P.closed(0, 1)
[0,1]
>>> P.closed(1, 2) | P.closed(2, 3) | P.closed(4, 5)
[1,3] | [4,5]

The first case merges two touching closed intervals into one. The second does the same for three parts written out of order. The third shows that union with the empty interval returns the other operand rather than an empty result. And the fourth is the interesting one: [1,2] and [2,3] merge into [1,3], but [4,5] stays separate, so the result is a disjunction of two atomic intervals rather than one.

That last behaviour is the one to understand before you rely on the library, and the README is explicit about it. By default, simplification of discrete intervals is not supported by portion, though it can be simulated, with a link to issue 24 and a specific comment in it. Combining [0,1] with [2,3] will not result in [0,3], even if there is no integer between 1 and 2.

The reason is that adjacency and overlap are different things over a continuous domain and over a discrete one. Over the reals, [1,2] and [2,3] share the point 2 and are therefore adjacent in a way that merging preserves. Over the integers, there is a gap between 1 and 2, so merging them would silently include a value that was never in either set.

The library takes the conservative option by default, and points you at the specialisation section for creating specialised discrete intervals if you want the other behaviour. The choice is made explicit in the documentation rather than left to be discovered, which is the right way to handle a default that is correct but not what every caller wants.

The topic list of the repository names the same vocabulary: datastructures, interval, interval-set, operations, range.

Interval attributes: empty, atomic and enclosure

Three properties describe the shape of an interval, and each has a definition that is worth reading closely because the edge cases are where interval libraries usually differ.

The empty property is True if and only if the interval is empty:

python
   >>> P.closed(0, 1).empty
   False
   >>> P.closed(0, 0).empty
   False
   >>> P.openclosed(0, 0).empty
   True
   >>> P.empty().empty
   True

The second case is the subtle one. A closed interval from 0 to 0 is a singleton and is not empty, while an open-closed interval from 0 to 0 contains nothing and is empty.

The atomic property is True if and only if the interval is empty or is a disjunction of a single interval:

python
   >>> P.empty().atomic
   True
   >>> P.closed(0, 2).atomic
   True
   >>> (P.closed(0, 1) | P.closed(1, 2)).atomic
   True
   >>> (P.closed(0, 1) | P.closed(2, 3)).atomic
   False

Note that the empty interval counts as atomic, since it is a disjunction of zero intervals and the definition allows that case explicitly. And two closed intervals that touch at 1 are atomic, because after simplification they are one interval.

The enclosure property refers to the smallest atomic interval that includes the current one:

python
   >>> (P.closed(0, 1) | P.open(2, 3)).enclosure
   [0,3)

The enclosure is the bounding interval of a disjunction, and the repr shows how the bound kinds are chosen at the edges: the lower bound is closed because 0 is in the set, and the upper bound is open because 3 is not.

The left and right boundaries and the lower and upper bounds are accessible with the left, right, lower and upper attributes. The left and right bounds are either P.CLOSED or P.OPEN, and by definition P.CLOSED is the negation of P.OPEN and vice versa, so a bound kind is one bit of information.

Installing it, and the shape of the project

Installation is the usual pip install portion, which pulls the latest available version from PyPI. Pre-releases are available from the master branch on GitHub and can be installed with pip install git+https://github.com/AlexandreDecan/portion, with the README's own warning not to trust pre-releases.

For development, the README gives pip install --group dev at the root of the repository, which installs portion and its development environment, automatically pulling in pytest for the test suites and ruff for code style.

That last detail reflects a change in the project's tooling, and the release names record it. Version 2.6.1 is titled the pyproject.toml release, and the pyproject.toml in the repository now uses hatchling as the build backend with a requires of hatchling. The dependency groups in that file list pytest at 8.4.2 or later, pytest-benchmark at 5.2.3 or later, pytest-cov at 7.0.0 or later and ruff at 0.14.7 or later, and the ruff configuration includes the portion package files.

The presence of pytest-benchmark in the dev group is a good clue about how the library treats its own performance, and it fits the 2.6.2 release title, which is the Do it faster! release. The 2.6.3 release a few months later suggests the performance work continued after the version that named it.

The runtime dependencies are minimal: sortedcontainers at 2.4 or later, and nothing else. That is the right dependency for the job, since the sorted container is what keeps the atomic intervals ordered and the sets of intervals searchable.

The project metadata is thorough for a library this small. It requires Python 3.10 or later, and the classifiers list 3.10 through 3.14, with a development status of 5, Production/Stable, and an audience that covers developers, education, information technology and science and research. The keyword list is interval, interval-set, range and span, which is a reasonable set of ways people look for this kind of thing.

The repository layout is conventional: the portion/ package, the tests/ directory, the pyproject.toml, a uv.lock for reproducible environments, a .python-version file, a CHANGELOG.md, a LICENSE.txt, the logo and a .github/ directory holding the workflows that produce the test and coverage badges in the README.

Editorial conclusion

Adopt portion if your problem is genuinely about ranges over comparable values, whether that is time windows, numeric bands, version ranges or string spans, because it gives you closed and open bounds, union and intersection with automatic simplification, and an interval to data mapping in one place. Do not adopt it as a general range type for a single domain where the standard library already fits, since it is a general library rather than a domain one. Before you rely on it, note that simplification of discrete intervals is off by default, so adjacent integer intervals do not merge unless you specialise the interval type, and read the specialisation section rather than assuming the default behaviour is what you want.

Frequently asked questions

What does the portion library provide?

A data structure and operations for intervals in Python: intervals of any comparable objects, closed or open and finite or semi-infinite, interval sets as a union of atomic intervals, automatic simplification, comparison, transformation, intersection, union, complement, difference and containment, tests for emptiness, atomicity, overlap and adjacency, discrete iteration, a dict-like mapping from intervals to data, and import and export to strings and Python built-in types.

How do I create intervals with portion?

With the helpers P.open, P.closed, P.openclosed, P.closedopen, P.singleton and P.empty. The bounds can be any comparable values, so P.closed('a', 'z') gives ['a','z'] and P.closed with datetime.date values works as well, and P.inf and -P.inf give infinite and semi-infinite bounds.

Does portion merge adjacent intervals automatically?

Over a continuous domain it does, so P.closed(0, 2) | P.closed(2, 4) becomes [0,4]. Simplification of discrete intervals is not supported by default, so combining [0,1] with [2,3] does not give [0,3] even though no integer lies between 1 and 2, and the README points to a specialisation section for that case.

What is an atomic interval in portion?

An interval is a disjunction of atomic intervals, and the atomic property is True if and only if the interval is empty or is a disjunction of a single interval. The empty interval counts as atomic, and the enclosure property gives the smallest atomic interval containing the current one.

How do I install portion and its development dependencies?

With pip install portion for the released version from PyPI, pip install git+https://github.com/AlexandreDecan/portion for a pre-release from the master branch, and pip install --group dev at the repository root to get the development environment, which installs pytest and ruff automatically.

Official sources

  1. AlexandreDecan/portion on GitHub
  2. Issues
  3. License: LGPL-3.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/alexandredecan-portion.svg)](https://hysenlabs.com/projects/alexandredecan-portion)

Was this useful?