diff --git a/CHANGELOG.md b/CHANGELOG.md index a71113f..9b2e1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/intervaltree/intervaltree.py b/intervaltree/intervaltree.py index e273127..fdf00a4 100644 --- a/intervaltree/intervaltree.py +++ b/intervaltree/intervaltree.py @@ -777,7 +777,7 @@ def merge_neighbors( self, data_reducer=None, data_initializer=None, - distance=1, + distance=None, strict=True, ): """ @@ -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] @@ -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: diff --git a/test/intervaltree_methods/restructure_test.py b/test/intervaltree_methods/restructure_test.py index 9d178b3..5ce2351 100644 --- a/test/intervaltree_methods/restructure_test.py +++ b/test/intervaltree_methods/restructure_test.py @@ -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 @@ -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)