Merge Overlapping Intervals

Normalize half-open intervals, merge overlaps or adjacent ranges, and reject invalid bounds with clear validation errors.

PythonData Integrity

Code

snippet
from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass


@dataclass(frozen=True, order=True)
class Interval:
    start: int
    end: int

    def __post_init__(self) -> None:
        if isinstance(self.start, bool) or isinstance(self.end, bool):
            raise TypeError("interval bounds must be integers")
        if not isinstance(self.start, int) or not isinstance(self.end, int):
            raise TypeError("interval bounds must be integers")
        if self.start >= self.end:
            raise ValueError(
                f"invalid interval [{self.start}, {self.end})"
            )


def _coerce(item: Interval | tuple[int, int]) -> Interval:
    if isinstance(item, Interval):
        return item
    if isinstance(item, tuple) and len(item) == 2:
        return Interval(item[0], item[1])
    raise TypeError("intervals must be Interval instances or (start, end) tuples")


def merge_intervals(
    intervals: Iterable[Interval | tuple[int, int]],
    *,
    adjacent: bool = True,
) -> list[Interval]:
    if not isinstance(adjacent, bool):
        raise TypeError("adjacent must be a boolean")

    ordered = sorted(_coerce(item) for item in intervals)
    merged: list[Interval] = []
    for interval in ordered:
        if not merged:
            merged.append(interval)
            continue

        previous = merged[-1]
        touches = (
            interval.start <= previous.end
            if adjacent
            else interval.start < previous.end
        )
        if touches:
            merged[-1] = Interval(
                previous.start,
                max(previous.end, interval.end),
            )
        else:
            merged.append(interval)
    return merged


def main() -> None:
    samples = [(1, 3), (2, 6), (8, 10), (10, 12), (20, 21)]
    print(merge_intervals(samples))
    print(merge_intervals(samples, adjacent=False))
    print(merge_intervals([]))

    try:
        merge_intervals([(5, 5)])
    except ValueError as error:
        print(error)

    try:
        merge_intervals([(1, 2), ("3", 4)])
    except TypeError as error:
        print(error)


if __name__ == "__main__":
    main()