Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- Better subclassing support: Determine classes dynamically,
so that methods like str() are aware when our types are subclassed.
- Removed no-op check and extra list allocation during chop()
- Fixed merge_neighbors() TypeError on non-numeric endpoints
(e.g. datetime): the default distance is now inferred from
the endpoint type instead of being the int 1
- Maintainers:
- Load issue4 test data via JSON to save on compilation and collection time.

Expand Down
15 changes: 13 additions & 2 deletions intervaltree/intervaltree.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,7 @@ def merge_neighbors(
self,
data_reducer=None,
data_initializer=None,
distance=1,
distance=None,
strict=True,
):
"""
Expand Down Expand Up @@ -806,12 +806,23 @@ def merge_neighbors(
will not be merged. If strict is False, both neighbors and overlapping
intervals are merged.

If distance is None (the default), it is inferred from the endpoints:
1 for numeric endpoints, or the zero of the endpoint type (e.g.
timedelta(0) for datetime endpoints) so that only touching or
overlapping intervals are merged.

Completes in O(n*logn) time.
"""
if not self:
return

sorted_intervals = sorted(self.all_intervals) # get sorted intervals
zero = sorted_intervals[0].begin - sorted_intervals[0].begin
if distance is None:
# type-aware default: numeric endpoints keep the historical
# default of 1, other endpoint types (e.g. datetime) default
# to zero so only touching/overlapping intervals are merged
distance = 1 if isinstance(zero, (int, float)) else zero
merged = []
# use mutable object to allow new_series() to modify it
current_reduced = [None]
Expand All @@ -832,7 +843,7 @@ def new_series():
lower = merged[-1]
margin = higher.begin - lower.end
if margin <= distance: # should merge
if strict and margin < 0:
if strict and margin < zero:
new_series()
continue
else:
Expand Down
58 changes: 56 additions & 2 deletions test/intervaltree_methods/restructure_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
limitations under the License.
"""
from __future__ import absolute_import
from datetime import datetime, timedelta
from intervaltree import Interval, IntervalTree
import pytest
from test import data
Expand Down Expand Up @@ -459,9 +460,62 @@ def reducer(old, new):
]


# -----------------------------------------------------------------------------
def test_merge_neighbors_non_numeric_endpoints():
# regression: merge_neighbors crashed with TypeError on non-numeric
# endpoints (e.g. datetime) because the default distance was the
# int 1 and the strict check compared margin against the int 0
def reducer(old, new):
return "%s, %s" % (old, new)

# touching datetime intervals merge by default
t = IntervalTree.from_tuples([
(datetime(2026, 1, 1), datetime(2026, 1, 2), '[1/1,1/2)'),
(datetime(2026, 1, 2), datetime(2026, 1, 3), '[1/2,1/3)'),
])
t.merge_neighbors(data_reducer=reducer)
t.verify()
assert len(t) == 1
for begin, end, _data in t.items():
assert begin == datetime(2026, 1, 1)
assert end == datetime(2026, 1, 3)
assert _data == '[1/1,1/2), [1/2,1/3)'

# a gap wider than the zero default stays unmerged
t = IntervalTree.from_tuples([
(datetime(2026, 1, 1), datetime(2026, 1, 2), '[1/1,1/2)'),
(datetime(2026, 1, 3), datetime(2026, 1, 4), '[1/3,1/4)'),
])
t.merge_neighbors()
t.verify()
assert len(t) == 2

# an explicit distance of the endpoint type still merges gaps
t = IntervalTree.from_tuples([
(datetime(2026, 1, 1), datetime(2026, 1, 2), '[1/1,1/2)'),
(datetime(2026, 1, 2, 2), datetime(2026, 1, 3), '[1/2,1/3)'),
])
t.merge_neighbors(data_reducer=reducer, distance=timedelta(hours=3))
t.verify()
assert len(t) == 1
for begin, end, _data in t.items():
assert begin == datetime(2026, 1, 1)
assert end == datetime(2026, 1, 3)
assert _data == '[1/1,1/2), [1/2,1/3)'

# overlapping datetime intervals under strict mode keep the split
# behavior instead of raising
t = IntervalTree.from_tuples([
(datetime(2026, 1, 1), datetime(2026, 1, 4), 'a'),
(datetime(2026, 1, 3), datetime(2026, 1, 5), 'b'),
])
t.merge_neighbors(strict=True)
t.verify()
assert len(t) == 2


# ------------------------------------------------------------------------------
# CHOP
# -----------------------------------------------------------------------------
# ------------------------------------------------------------------------------
def test_chop():
t = IntervalTree([Interval(0, 10)])
t.chop(3, 7)
Expand Down