From 38cd52e315acb8a35667e9dfcf453117ee57d74d Mon Sep 17 00:00:00 2001 From: Peter Byfield Date: Mon, 13 Jan 2025 10:03:04 +0100 Subject: [PATCH 1/5] Add test case to ensure that any_overlapping does not modify ranges --- tests/test_ranges.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index 5545f8d..1811b5f 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -589,6 +589,17 @@ def test_timestamps_sorted(self, period): class TestAnyOverlapping: + def test_does_not_modify_ranges(self): + # The implementation of `any_overlapping` relies on sorting. + # Let's make sure that the ranges passed in are unchanged. + ranges_ = [ + ranges.Range(1, 2), + ranges.Range(0, 1), + ] + ranges_copy = ranges_.copy() + assert not ranges.any_overlapping(ranges_) + assert ranges_ == ranges_copy + @pytest.mark.parametrize( "ranges_", [ From 7dbfc82171e9a12a85297bc5d54554b81b091b7b Mon Sep 17 00:00:00 2001 From: Peter Byfield Date: Mon, 13 Jan 2025 10:03:43 +0100 Subject: [PATCH 2/5] Test any_overlapping against reversed ranges list To give us even more confidence that the order of the input ranges does not matter. --- tests/test_ranges.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index 1811b5f..c8d0f72 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -624,6 +624,7 @@ def test_does_not_modify_ranges(self): ) def test_returns_true_if_and_ranges_overlap(self, ranges_): assert ranges.any_overlapping(ranges_) + assert ranges.any_overlapping(reversed(ranges_)) @pytest.mark.parametrize( "ranges_", @@ -642,6 +643,7 @@ def test_returns_true_if_and_ranges_overlap(self, ranges_): ) def test_returns_false_if_no_ranges_overlap(self, ranges_): assert not ranges.any_overlapping(ranges_) + assert not ranges.any_overlapping(reversed(ranges_)) def test_returns_false_for_empty_set_of_ranges(self): assert not ranges.any_overlapping([]) From d0671e3f814ef57855c8dac4eeef8ce272336234 Mon Sep 17 00:00:00 2001 From: Peter Byfield Date: Mon, 13 Jan 2025 10:14:34 +0100 Subject: [PATCH 3/5] Add ranges.any_gaps --- tests/test_ranges.py | 81 ++++++++++++++++++++++++++++++++++++++++++++ xocto/ranges.py | 5 +++ 2 files changed, 86 insertions(+) diff --git a/tests/test_ranges.py b/tests/test_ranges.py index c8d0f72..3c81b04 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -649,6 +649,87 @@ def test_returns_false_for_empty_set_of_ranges(self): assert not ranges.any_overlapping([]) +class TestAnyGaps: + def test_does_not_modify_ranges(self): + # The implementation of `any_gaps` relies on sorting. + # Let's make sure that the ranges passed in are unchanged. + ranges_ = [ + ranges.Range(1, 2), + ranges.Range(0, 1), + ] + ranges_copy = ranges_.copy() + assert not ranges.any_gaps(ranges_) + assert ranges_ == ranges_copy + + @pytest.mark.parametrize( + "ranges_", + [ + [ + ranges.Range(0, 1), + ranges.Range(2, 3), + ], + [ + ranges.Range( + 0, 1, boundaries=ranges.RangeBoundaries.INCLUSIVE_EXCLUSIVE + ), + ranges.Range( + 1, 2, boundaries=ranges.RangeBoundaries.EXCLUSIVE_INCLUSIVE + ), + ], + [ + ranges.Range(0, 2), + ranges.Range(4, 6), + ranges.Range(1, 3), + ], + ], + ) + def test_returns_true_if_gaps(self, ranges_): + assert ranges.any_gaps(ranges_) + assert ranges.any_gaps(reversed(ranges_)) + + @pytest.mark.parametrize( + "ranges_", + [ + [ + ranges.Range(0, 1), + ranges.Range(1, 2), + ], + [ + ranges.Range( + 0, 1, boundaries=ranges.RangeBoundaries.EXCLUSIVE_INCLUSIVE + ), + ranges.Range( + 1, 2, boundaries=ranges.RangeBoundaries.EXCLUSIVE_INCLUSIVE + ), + ], + [ + ranges.Range(0, 2), + ranges.Range(1, 3), + ], + [ + ranges.Range(0, 3), + ranges.Range(1, 2), + ], + [ + ranges.Range(0, 5), + ranges.Range(1, 2), + ranges.Range(3, 4), + ], + [ + ranges.Range(0, 2), + ranges.Range(4, 6), + ranges.Range(2, 4), + ], + ], + ) + def test_returns_false_if_no_gaps(self, ranges_): + assert not ranges.any_gaps(ranges_) + assert not ranges.any_gaps(reversed(ranges_)) + + def test_returns_false_for_empty_set_of_ranges(self): + assert not ranges.any_gaps([]) + + class TestFiniteDateRange: """ Test class for methods specific to the the FiniteDateRange subclass. diff --git a/xocto/ranges.py b/xocto/ranges.py index 31af64a..add25fe 100644 --- a/xocto/ranges.py +++ b/xocto/ranges.py @@ -1067,6 +1067,11 @@ def any_overlapping(ranges: Iterable[Range[T]]) -> bool: return False +def any_gaps(ranges: Iterable[Range[T]]) -> bool: + """Return true if there are gaps between the passed Ranges.""" + return len(RangeSet(ranges)) > 1 + + def as_finite_datetime_periods( periods: Iterable[HalfFiniteDatetimeRange | DatetimeRange], ) -> Sequence[FiniteDatetimeRange]: From 54f2d3394ea9316b0c3b37f42d7d9fcfba401b29 Mon Sep 17 00:00:00 2001 From: Peter Byfield Date: Mon, 13 Jan 2025 10:21:44 +0100 Subject: [PATCH 4/5] Add benchmark for ranges.any_gaps --- tests/benchmarks/test_ranges.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/benchmarks/test_ranges.py b/tests/benchmarks/test_ranges.py index b24fd28..f572e8f 100644 --- a/tests/benchmarks/test_ranges.py +++ b/tests/benchmarks/test_ranges.py @@ -1,13 +1,27 @@ import random from decimal import Decimal as D +import pytest + from xocto import ranges -def test_any_overlapping(benchmark): - ranges_ = [ranges.Range(D(i), D(i + 1)) for i in range(1000)] - random.seed(42) +def _shuffled(ranges_, *, seed=42): + ranges_ = ranges_.copy() + random.seed(seed) random.shuffle(ranges_) + return ranges_ + +@pytest.mark.benchmark(group="ranges.any_overlapping") +def test_any_overlapping(benchmark): + ranges_ = _shuffled([ranges.Range(D(i), D(i + 1)) for i in range(1000)]) any_overlapping = benchmark(ranges.any_overlapping, ranges_) assert any_overlapping is False + + +@pytest.mark.benchmark(group="ranges.any_gaps") +def test_any_gaps(benchmark): + ranges_ = _shuffled([ranges.Range(D(i), D(i + 1)) for i in range(1000)]) + any_overlapping = benchmark(ranges.any_gaps, ranges_) + assert any_overlapping is False From e927d8228050bdf8ed11557e3570a3d41cdebc14 Mon Sep 17 00:00:00 2001 From: Peter Byfield Date: Mon, 13 Jan 2025 10:26:09 +0100 Subject: [PATCH 5/5] Update change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f35570e..325c89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `ranges.any_gaps` function [#185](https://github.com/octoenergy/xocto/pull/185). - Improve the performance of the `ranges.any_overlapping` function (with some benchmark showing a >100x speed up) [#184](https://github.com/octoenergy/xocto/pull/184).