From e3f1dcf8215916caf9fb3104006f7ef3c59c3f33 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Fri, 23 Jun 2023 21:22:44 +0100 Subject: [PATCH 01/11] ignore profiler files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a8b53fc..d239629 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__ test-output.xml site/* docs/pydoc.md +*.prof From 23488903531f399162a096555a35dc7c88fa5f48 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 12:05:33 +0100 Subject: [PATCH 02/11] rearrange --- .vscode/settings.json | 6 +- arranges/pyproject.toml | 2 +- arranges/src/arranges/__init__.py | 2 +- arranges/src/arranges/_range.py | 107 ++++ arranges/src/arranges/range.py | 503 +++++------------- arranges/src/arranges/ranges.py | 149 ------ arranges/src/arranges/segment.py | 242 +++++++++ arranges/src/arranges/utils.py | 10 + .../tests/{ranges => range}/test_combine.py | 36 +- .../tests/range/test_construct_iterable.py | 9 +- .../tests/range/test_construct_rangelike.py | 37 +- arranges/tests/range/test_construct_str.py | 27 +- arranges/tests/range/test_construct_values.py | 13 +- arranges/tests/range/test_construction.py | 37 ++ arranges/tests/range/test_contains.py | 2 +- arranges/tests/range/test_equality.py | 12 +- arranges/tests/range/test_intersection.py | 2 +- .../{ranges => range}/test_membership.py | 10 +- arranges/tests/range/test_operators.py | 6 +- arranges/tests/range/test_repr.py | 46 -- arranges/tests/range/test_union.py | 11 - arranges/tests/range/test_validator.py | 12 +- arranges/tests/ranges/__init__.py | 0 arranges/tests/ranges/test_construction.py | 37 -- arranges/tests/ranges/test_equality.py | 10 - arranges/tests/ranges/test_repr.py | 21 - arranges/tests/ranges/test_validator.py | 41 -- arranges/tests/segment/__init__.py | 12 + arranges/tests/utils/test_try_hash.py | 9 + 29 files changed, 636 insertions(+), 775 deletions(-) create mode 100644 arranges/src/arranges/_range.py delete mode 100644 arranges/src/arranges/ranges.py create mode 100644 arranges/src/arranges/segment.py rename arranges/tests/{ranges => range}/test_combine.py (58%) create mode 100644 arranges/tests/range/test_construction.py rename arranges/tests/{ranges => range}/test_membership.py (73%) delete mode 100644 arranges/tests/range/test_repr.py delete mode 100644 arranges/tests/ranges/__init__.py delete mode 100644 arranges/tests/ranges/test_construction.py delete mode 100644 arranges/tests/ranges/test_equality.py delete mode 100644 arranges/tests/ranges/test_repr.py delete mode 100644 arranges/tests/ranges/test_validator.py create mode 100644 arranges/tests/segment/__init__.py create mode 100644 arranges/tests/utils/test_try_hash.py diff --git a/.vscode/settings.json b/.vscode/settings.json index abc290b..8de6eb0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,9 @@ "python.testing.unittestEnabled": false, "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "python.analysis.extraPaths": ["${workspaceFolder}/merge-files/src"], - "python.envFile": "${workspaceFolder}/.env" + "python.envFile": "${workspaceFolder}/.env", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.formatting.provider": "none" } diff --git a/arranges/pyproject.toml b/arranges/pyproject.toml index 2fdc3ea..01a8f7b 100644 --- a/arranges/pyproject.toml +++ b/arranges/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "arranges" description = "Mergable range object for use in Pydantic classes" -version = "0.0.6" +version = "0.1.0" authors = [ { name = "Gareth Davidson", email = "gaz@bitplane.net" } ] diff --git a/arranges/src/arranges/__init__.py b/arranges/src/arranges/__init__.py index bfa62b1..25b7911 100644 --- a/arranges/src/arranges/__init__.py +++ b/arranges/src/arranges/__init__.py @@ -1,3 +1,3 @@ from arranges.range import Range # noqa -from arranges.ranges import Ranges # noqa +from arranges.segment import Segment # noqa from arranges.utils import inf # noqa diff --git a/arranges/src/arranges/_range.py b/arranges/src/arranges/_range.py new file mode 100644 index 0000000..03c2249 --- /dev/null +++ b/arranges/src/arranges/_range.py @@ -0,0 +1,107 @@ +# from typing import Any + +# from arranges.segment import Segment +# from arranges.utils import as_type, inf, is_intlike, is_iterable, is_rangelike, to_int + + +# class Range: +# """ +# A range of numbers, similar to Python slice notation, but with no step or +# negative/relative ranges. +# """ + +# start: int = 0 +# stop: int = inf + +# def isdisjoint(self, other: Any) -> bool: +# """ +# Return True if this range is disjoint from the other range +# """ +# other = as_type(Range, other) +# return not self.intersects(other) + +# def issubset(self, other: Any) -> bool: +# """ +# Return True if this range is a subset of the other range +# """ +# other = as_type(Range, other) +# return self in other + +# def __le__(self, other: Any) -> bool: +# """ +# Return True if this range is a subset of the other range +# """ +# other = as_type(Range, other) +# return self in other + +# def __lt__(self, other: Any) -> bool: +# """ +# Return True if this range is a proper subset of the other range +# """ +# other = as_type(Range, other) +# return self in other and self != other + +# def issuperset(self, other: Any) -> bool: +# """ +# Return True if this range is a superset of the other range +# """ +# other = as_type(Range, other) +# return other in self + +# def __ge__(self, other: Any) -> bool: +# """ +# Return True if this range is a superset of the other range +# """ +# other = as_type(Range, other) +# return other in self + +# def __gt__(self, other: Any) -> bool: +# """ +# Return True if this range is a proper superset of the other range +# """ +# other = as_type(Range, other) +# return other in self and self != other + +# def __invert__(self) -> "Range": +# """ +# Return the inverse of this range (compliment) +# """ +# if not self: +# return Range(0, inf) + +# if self.start == 0: +# return Range(self.stop, inf) + +# if self.stop == inf: +# return Range(0, self.start) + +# raise ValueError("Inverting this range will cause a discontinuity") + +# def __and__(self, other: "Range") -> "Range": +# """ +# Return the intersection of this range and the other range +# """ +# return self.intersection(other) + +# def __sub__(self, other: "Range") -> "Range": +# """ +# Return the difference between this range and the other +# """ +# if not self.intersects(other): +# return Range(self) + +# # def difference(self, *others): +# # """ +# # Remove the other ranges from this one +# # """ +# # + +# # def symmetric_difference(self, other: "Range") -> "Range": +# # """ +# # Return the symmetric difference of two ranges +# # """ + +# # def __xor__(self, other: "Range") -> "Range": +# # """ +# # Return the symmetric difference of two ranges +# # """ diff --git a/arranges/src/arranges/range.py b/arranges/src/arranges/range.py index 2917ec0..e379096 100644 --- a/arranges/src/arranges/range.py +++ b/arranges/src/arranges/range.py @@ -1,432 +1,205 @@ -from typing import Any, Tuple +import re +from functools import lru_cache +from typing import Any, Iterable -from arranges.utils import as_type, inf, is_intlike, is_iterable, is_rangelike, to_int +from arranges.segment import Segment, range_idx +from arranges.utils import is_intlike, is_iterable, is_rangelike, try_hash -class Range: +class Range(str): """ - A range of numbers, similar to Python slice notation, but with no step or - negative/relative ranges. + A range set that can be hashed and converted to a string. """ - start: int = 0 - stop: int = inf + segments: tuple[Segment] + step: int = 1 - def __init__(self, value: Any, stop: int = None): + def __init__(self, value: Any, stop: range_idx | None = None): """ - Construct a range from either a value: - - If `value` is a string, it'll use `Range.from_str` and expect a string - in slice notation. - - If `value` is an iterable, it'll use `Range.from_iterable` and expect - a sequence of consecutive integers. - - If `value` is an object with `start`, `stop` and `step` value, it'll - use the start and stop values from that, though the `step` value must - be 1. - - If `value` is an integer, it'll use that as the stop value, just like a - Python range or slice. - - If a `value` and `stop` are provided then `value` is the start like in - a range or slice. + Construct a new string with the canonical form of the range. """ - start_and_stop = value is not None and stop is not None - - if start_and_stop: - self.start = value - self.stop = stop - - else: - if is_rangelike(value): - if value.step not in (None, 1): - raise ValueError("Step values are not supported") - - self.start = 0 if value.start is None else value.start - self.stop = inf if value.stop is None else value.stop - elif isinstance(value, str): - other = self.from_str(value) - self.start = other.start - self.stop = other.stop - elif is_iterable(value): - other = self.from_iterable(value) - self.start = other.start - self.stop = other.stop - else: - self.start = 0 - self.stop = value - - # Convert to integers - self.start = int(self.start) - if self.stop is not inf: - self.stop = int(self.stop) - - self.step = 1 - - if self.start < 0 or self.stop < 0: - raise ValueError("Start and stop must be positive") - - if self.start > self.stop: - raise ValueError("Stop can't be before start") + self.segments = self.from_str(self) - if self.start == self.stop: - self.start = self.stop = 0 - - super().__init__() - - @classmethod - def from_str(cls, value: str) -> "Range": + def __new__(cls, value: Any, stop: range_idx | None = None) -> str: """ - Construct Range from a string, Python slice notation. e.g. "100:" or - "1:10". - - Hex, octal and binary numbers are supported, using Python syntax, e.g. - "0x10:0x20" or "0o10:0o20" or "0b100:0b110". + Construct a new string with the canonical form of the range. - Special values "start", "inf" and "end" can be used, which are considered - to be 0 and `inf` respectively. - - An empty string is treated to be an empty range, and ":" is the full range. + This becomes "self" in __init__, so we're always a string """ - vals = [v.strip() for v in str(value).split(":")] - - if len(vals) > 2: - raise ValueError("Too many values, Only start and stop are allowed") - - if len(vals) == 1: - if not vals[0]: - # Empty range - start = stop = 0 - else: - # single value - start = to_int(vals[0], 0) - stop = start + 1 - else: - # start:stop - start = to_int(vals[0], 0) - stop = to_int(vals[1], inf) - - return cls(start, stop) + return str.__new__(cls, cls.construct_str(value, stop)) @classmethod - def from_iterable(cls, values: Any) -> "Range": + def construct_str(cls, value, stop) -> str: """ - Construct Range from an iterable + Create a string representation of a range series """ - values = list(set(values)) - values.sort() - - if not values or len(values) == 1: - return Range(0, 0) - - start = int(values[0]) - stop = start - - for value in values: - if not is_intlike(value) or value < 0: - raise ValueError("Only positive integers are allowed") - - if value > stop: - raise ValueError("Continuous ranges only") - - if value == stop: - stop += 1 - - return Range(start, stop) - - @staticmethod - def sort_key(value: "Range") -> Tuple[int]: - """ - Sort key function for sorting ranges - """ - return value.start, value.stop - - def __repr__(self): - """ - Python representation - """ - name = self.__class__.__name__ - if not self: - return f"{name}(0, 0)" - if not self.start and self.stop is inf: - return f"{name}(0, inf)" - if self.start == 0: - return f"{name}({self.stop})" + start_and_stop = value is not None and stop is not None + stop_only = value is None and stop is not None - return f"{name}({self.start}, {self.stop})" + if start_and_stop or stop_only: + return Segment(value, stop) - def __str__(self): - """ - Convert to a string - """ - if not self: - return "" - if not self.start and self.stop == inf: - return ":" - if not self.start: - return f":{self.stop}" - if self.stop == inf: - return f"{self.start}:" - if self.stop == self.start + 1: - return str(self.start) - - return f"{self.start}:{self.stop}" + if value is None: + raise TypeError("Got 0 arguments, expected 1 or 2") - def __hash__(self) -> int: - return hash(str(self)) + if is_intlike(value): + return Segment(0, value) - def __eq__(self, other: Any) -> bool: - """ - Compare two ranges - """ - if is_intlike(other): - return self.start == other and self.stop == other + 1 - if isinstance(other, str): - return self == self.from_str(other) - if not self and not other: - return True + if is_rangelike(value): + if not value.step or value.step == 1: + return Segment(value.start, value.stop) + else: + stepped_range = list(value) + return cls.iterable_to_str(stepped_range) - try: - other = as_type(Range, other) - except (TypeError, ValueError): - return False + if hasattr(value, "segments"): + return ",".join(value.segments) - return self.start == other.start and self.stop == other.stop + if isinstance(value, str): + normalized = cls.from_str(value) + return ",".join(str(v) for v in normalized) - def isdisjoint(self, other: Any) -> bool: - """ - Return True if this range is disjoint from the other range - """ - other = as_type(Range, other) - return not self.intersects(other) + if is_iterable(value): + return cls.iterable_to_str(value) - def issubset(self, other: Any) -> bool: - """ - Return True if this range is a subset of the other range - """ - other = as_type(Range, other) - return self in other - - def __le__(self, other: Any) -> bool: - """ - Return True if this range is a subset of the other range - """ - other = as_type(Range, other) - return self in other + raise TypeError(f"Cannot convert {value} into {cls.__name__}") - def __lt__(self, other: Any) -> bool: - """ - Return True if this range is a proper subset of the other range - """ - other = as_type(Range, other) - return self in other and self != other + @staticmethod + def split_str(value: str) -> list[str]: + return re.split(r",|;", value) - def issuperset(self, other: Any) -> bool: + @classmethod + def from_str(cls, value: str) -> tuple[Segment]: """ - Return True if this range is a superset of the other range + Construct from a string. """ - other = as_type(Range, other) - return other in self + ret = [] - def __ge__(self, other: Any) -> bool: - """ - Return True if this range is a superset of the other range - """ - other = as_type(Range, other) - return other in self + for s in cls.split_str(value): + ret.append(Segment.from_str(s)) - def __gt__(self, other: Any) -> bool: - """ - Return True if this range is a proper superset of the other range - """ - other = as_type(Range, other) - return other in self and self != other + cacheable = tuple(set(ret)) - def union(self, *others) -> "Range": - """ - Return the union of this range and the other ranges - """ - ret = self - for other in others: - ret = ret | other - return ret + return cls.from_hashable_iterable(cacheable) - def __invert__(self) -> "Range": + @classmethod + def iterable_to_str(cls, iterable: Iterable) -> str: """ - Return the inverse of this range (compliment) + Convert an iterable of ranges to a string """ - if not self: - return Range(0, inf) - - if self.start == 0: - return Range(self.stop, inf) - - if self.stop == inf: - return Range(0, self.start) + hashable = tuple(iterable) + if try_hash(hashable): + vals = cls.from_hashable_iterable(hashable) + else: + vals = cls.from_iterable(hashable) - raise ValueError("Inverting this range will cause a discontinuity") + return ",".join(str(v) for v in vals) - def __or__(self, other: Any) -> "Range": + @classmethod + @lru_cache + def from_hashable_iterable(cls, value: tuple[Segment]) -> tuple[Segment]: """ - Return the union of this range and the other range + Cache the result of from_iterable """ - if not other: - return self - if not self: - return other - - if not self.isconnected(other): - raise ValueError(f"{self} and {other} aren't touching") + return cls.from_iterable(value) - start = min(self.start, other.start) - stop = max(self.stop, other.stop) - - return Range(start, stop) + @staticmethod + def _flatten(iterable: Iterable) -> Iterable[Segment]: + """ + Flatten into RangeSegments + """ + for item in iterable: + if isinstance(item, Segment): + yield item + if isinstance(item, Range): + yield from item.segments + elif isinstance(item, str): + if item: + yield from [Segment.from_str(s) for s in Range.split_str(item)] + elif is_iterable(item): + yield from Range._flatten(item) + elif is_intlike(item): + yield Segment(item, item + 1) + else: + yield from Range(item).segments - def intersection(self, *others: "Range") -> "Range": + @classmethod + def from_iterable(cls, iterable: Iterable) -> tuple[Segment]: """ - Return the intersection of this range and the other ranges + Sort and merge a list of ranges. """ - ret: Range = self + segments: list[Segment] = [] + segments.extend(cls._flatten(iterable)) + segments.sort(key=Segment.sort_key) - for other in others: - if not ret.intersects(other): - return Range(0, 0) + i = 1 - start = max(ret.start, other.start) - stop = min(ret.stop, other.stop) - if start >= stop: - return Range(0, 0) + while i < len(segments): + current = segments[i] + last = segments[i - 1] + if last.isconnected(current): + segments[i - 1] = current.union(last) + del segments[i] + i -= 1 + i += 1 - ret = Range(start, stop) + return tuple(segments) - return ret + def __hash__(self): + return super().__hash__() - def __and__(self, other: "Range") -> "Range": - """ - Return the intersection of this range and the other range - """ - return self.intersection(other) + def __add__(self, other): + s = self.iterable_to_str(tuple([self, other])) + return Range(s) - def __sub__(self, other: "Range") -> "Range": + def __eq__(self, other: Any) -> bool: """ - Return the difference between this range and the other + Compare the two lists based on their string representations """ - if not self.intersects(other): - return Range(self) - - # def difference(self, *others): - # """ - # Remove the other ranges from this one - # """ - # - - # def symmetric_difference(self, other: "Range") -> "Range": - # """ - # Return the symmetric difference of two ranges - # """ - - # def __xor__(self, other: "Range") -> "Range": - # """ - # Return the symmetric difference of two ranges - # """ + if not isinstance(other, Range): + other = Range(other) + return super().__eq__(other) def __contains__(self, other: Any) -> bool: """ - Membership test. Supports integers, strings, ranges and iterables. + Are all of the other ranges in our ranges? """ - if is_intlike(other): - return self.start <= other <= self.last - - if not self: # nothing fits in an empty range - return False - - if is_rangelike(other): - if not other: - return True # the empty set is a subset of all other sets - - inf_stop = other.stop or inf - start_inside = not self.start or other.start in self - last_inside = self.stop is None or (inf_stop - 1) in self - - return start_inside and last_inside - - if isinstance(other, str): - return self.from_str(other) in self - - if is_iterable(other): - for o in other: - if o not in self: - return False - return True - - raise TypeError(f"Unsupported type {type(other)}") + combined = str(self + other) + return self and combined == self def __iter__(self): """ - Iterate over the values in this range - """ - i = self.start - while i < self.stop: - yield i - i += 1 - - def __len__(self) -> int: - """ - Get the length of this range - """ - if self.start == self.stop: - return 0 - - if self.stop == inf: - return inf.__index__() - - return self.stop - self.start + Iterate over the values in our ranges. - def __bool__(self) -> bool: + Note that this could be boundless. """ - True if this range has a length - """ - return len(self) > 0 + for r in self.segments: + for i in r: + yield i - @property - def last(self) -> int: + def intersects(self, other: Any) -> bool: """ - Gets the last value in this range. Will return inf if the range - has no end, and -1 if it has no contents, + True if this range overlaps with the other range """ - if not self: - return -1 - return self.stop - 1 - - def intersects(self, other: "Range") -> bool: - """ - True if this range intersects the other range. - """ - if self in other or other in self: - return True - - if self.start in other or other.start in self: - return True + other: Range = Range(other) + for r in self.segments: + for o in other.segments: + if r.intersects(o): + return True return False - def isadjacent(self, other: "Range") -> bool: + def union(self, other) -> "Range": """ - True if this range is adjacent to the other range + Return the union of this range and the other """ - if self.stop == other.start or other.stop == self.start: - return True + return Range(self + other) - return False - - def isconnected(self, other: "Range") -> bool: + def __or__(self, other: "Range") -> "Range": """ - True if this range is adjacent to or overlaps the other range, and so they - can be joined together. + Return the union of this range and the other """ - return self.isadjacent(other) or self.intersects(other) + return self.union(other) @classmethod def validate(cls, value: Any) -> "Range": @@ -436,11 +209,19 @@ def validate(cls, value: Any) -> "Range": if isinstance(value, cls): return value - if isinstance(value, str): - return cls.from_str(value) - return cls(value) @classmethod def __get_validators__(cls): + """ + For automatic validation in pydantic + """ yield cls.validate + + @property + def first(self): + return self.segments[0].start + + @property + def last(self): + return self.segments[-1].last diff --git a/arranges/src/arranges/ranges.py b/arranges/src/arranges/ranges.py deleted file mode 100644 index efd98fc..0000000 --- a/arranges/src/arranges/ranges.py +++ /dev/null @@ -1,149 +0,0 @@ -from typing import Any, List - -from arranges.range import Range -from arranges.utils import is_intlike, is_iterable, is_rangelike - - -class Ranges: - """ - An ordered set of ranges that are combined and sorted - """ - - ranges: list[Range] - - def __init__(self, value=""): - self.ranges = [] - self.append(value) - - super().__init__() - - def append(self, value: Any) -> None: - """ - Add to the list of ranges. - - This mutates the object. - """ - # deal with non-range objects - if not isinstance(value, Range): - ranges = Ranges.flatten(value) - ranges.sort(key=Range.sort_key) - - for r in ranges: - self.append(r) - return - - # absorb range objects - self.ranges.append(value) - self.ranges.sort(key=Range.sort_key) - - i = 1 - - while i < len(self.ranges): - current = self.ranges[i] - last = self.ranges[i - 1] - if last.isconnected(current): - self.ranges[i - 1] = current.union(last) - del self.ranges[i] - i -= 1 - i += 1 - - def __eq__(self, other: "Ranges") -> bool: - """ - Compare the two lists based on their string representations - """ - return str(self) == str(other) - - def __repr__(self) -> str: - """ - Return a code representation of this bunch of ranges - """ - return f'{self.__class__.__name__}("{str(self)}")' - - def __str__(self) -> str: - """ - Return a string representation of this bunch of ranges - """ - return ",".join(str(r) for r in self.ranges) - - def __add__(self, other: Any) -> "Ranges": - """ - Combine this range with another range - """ - new = Ranges(self) - new.append(Ranges(other)) - return new - - def __contains__(self, other: Any) -> bool: - """ - Are all of the other ranges in our ranges? - """ - return str(self) == str(self + other) - - def __iter__(self): - """ - Iterate over the values in our ranges. - - Note that this could be boundless. - """ - for r in self.ranges: - for i in r: - yield i - - def intersects(self, other: Any) -> bool: - """ - True if this range overlaps with the other range - """ - other: Ranges = Ranges(other) - for r in self.ranges: - for o in other.ranges: - if r.intersects(o): - return True - - return False - - @classmethod - def validate(cls, value: Any) -> "Range": - """ - Validate a value and convert it to a Range - """ - if isinstance(value, cls): - return value - - return cls(value) - - @classmethod - def __get_validators__(cls): - """ - For automatic validation in pydantic - """ - yield cls.validate - - @staticmethod - def flatten(obj: Any, _current=None) -> List["Range"]: - """ - Coerce an object into a list of ranges. - - _current is used internally to keep track of the current - """ - if _current is None: - _current = [] - - if is_rangelike(obj): - _current.append(Range(obj)) - elif hasattr(obj, "ranges"): - for r in obj.ranges: - Ranges.flatten(r, _current=_current) - elif isinstance(obj, str): - for s in obj.split(","): - _current.append(Range.from_str(s)) - elif is_intlike(obj): - # todo: extend last range in case we're iterating over - # a large sequence - _current.append(Range(obj, obj + 1)) - elif is_iterable(obj): - for item in obj: - Ranges.flatten(item, _current=_current) - else: - raise TypeError(f"Unsupported type {type(obj)}") - - return _current diff --git a/arranges/src/arranges/segment.py b/arranges/src/arranges/segment.py new file mode 100644 index 0000000..c93763f --- /dev/null +++ b/arranges/src/arranges/segment.py @@ -0,0 +1,242 @@ +import re +from functools import lru_cache +from typing import Any + +from arranges.utils import as_type, inf, is_intlike, is_iterable, is_rangelike, to_int + +range_idx = int | float + + +def fix_start_stop(start: range_idx, stop: range_idx) -> tuple[range_idx, range_idx]: + start = 0 if start is None else int(start) + stop = inf if stop in (None, inf) else int(stop) + + if start > stop: + raise ValueError(f"Stop ({stop}) can't be before start ({start})") + + if start < 0 or stop < 0: + raise ValueError("Can't have a range with negative values") + + return start, stop + + +def start_stop_to_str(start: range_idx, stop: range_idx) -> str: + """ + Returns a string representation of a range from start to stop. + """ + start, stop = fix_start_stop(start, stop) + + if start == stop: + return "" + + if stop == start + 1: + return str(int(start)) + + start_str = str(int(start)) if start else "" + stop_str = str(int(stop)) if stop is not inf else "" + + return f"{start_str}:{stop_str}" + + +class Segment(str): + """ + A single range segment that's a string and can be hashed. + """ + + start: int = 0 + stop: int = inf + step: int = 1 + + def __init__(self, start: range_idx, stop: range_idx = None): + """ + Construct a new string with the canonical form of the range. + """ + self.start, self.stop = fix_start_stop(start, stop) + + def __new__(cls, start: range_idx, stop: range_idx = None) -> str: + """ + Construct a new string with the canonical form of the range. + """ + return str.__new__(cls, start_stop_to_str(start, stop)) + + def __hash__(self): + return hash(str(self)) + + def __or__(self, other: "Segment") -> "Segment": + """ + Return the union of this range and the other range + """ + if not other: + return self + if not self: + return other + + if not self.isconnected(other): + raise ValueError(f"{self} and {other} aren't touching") + + start = min(self.start, other.start) + stop = max(self.stop, other.stop) + + return Segment(start, stop) + + def __iter__(self): + """ + Iterate over the values in this range + """ + i = self.start + while i < self.stop: + yield i + i += 1 + + def __len__(self) -> int: + """ + Get the length of this range + """ + if self.start == self.stop: + return 0 + + if self.stop == inf: + return inf.__index__() + + return self.stop - self.start + + def __bool__(self) -> bool: + """ + True if this range has a length + """ + return len(self) > 0 + + @property + def last(self) -> int: + """ + Gets the last value in this range. Will return inf if the range + has no end, and -1 if it has no contents, + """ + if not self: + return -1 + return self.stop - 1 + + @classmethod + @lru_cache + def from_str(cls, value: str) -> "Segment": + """ + Construct from a string. + """ + vals = [v.strip() for v in re.split(r":|\-", value)] + + if len(vals) > 2: + raise ValueError(f"Too many values in {value} ({vals})") + + if len(vals) == 1: + if vals[0] == "": + # Empty range + start = stop = 0 + else: + # single value + start = to_int(vals[0], 0) + stop = start + 1 + else: + # start:stop + start = to_int(vals[0], 0) + stop = to_int(vals[1], inf) + + return cls(start, stop) + + @staticmethod + def sort_key(value: "Segment") -> tuple[int, int]: + """ + Sort key function for sorting ranges + """ + return value.start, value.stop + + def isdisjoint(self, other: Any) -> bool: + """ + Return True if this range is disjoint from the other range + """ + other = as_type(Segment, other) + return not self.intersects(other) + + def __eq__(self, other: Any) -> bool: + """ + Compare two segments + """ + if is_intlike(other): + return self.start == other and self.stop == other + 1 + if isinstance(other, str): + return str(self) == str(other) + if not self and not other: + return True + + try: + other = as_type(Segment, other) + return self.start == other.start and self.stop == other.stop + except (TypeError, ValueError): + return False + + def union(self, *others) -> "Segment": + """ + Return the union of this segment and the others + """ + ret = self + for other in others: + ret = ret | other + return ret + + def isconnected(self, other: "Segment") -> bool: + """ + True if this range is adjacent to or overlaps the other range, and so they + can be joined together. + """ + return self.isadjacent(other) or self.intersects(other) + + def isadjacent(self, other: "Segment") -> bool: + """ + True if this range is adjacent to the other range + """ + if self.stop == other.start or other.stop == self.start: + return True + + return False + + def intersects(self, other: "Segment") -> bool: + """ + True if this range intersects the other range. + """ + if self in other or other in self: + return True + + if self.start in other or other.start in self: + return True + + return False + + def __contains__(self, other: Any) -> bool: + """ + Membership test. Supports integers, strings, ranges and iterables. + """ + if is_intlike(other): + return self.start <= other <= self.last + + if not self: # nothing fits in an empty set + return False + + if is_rangelike(other): + if not other: + return True # the empty set is a subset of all other sets + + inf_stop = other.stop or inf + start_inside = not self.start or other.start in self + last_inside = self.stop is None or (inf_stop - 1) in self + + return start_inside and last_inside + + if isinstance(other, str): + return self.__class__(other) in self + + if is_iterable(other): + for o in other: + if o not in self: + return False + return True + + raise TypeError(f"Unsupported type {type(other)}") diff --git a/arranges/src/arranges/utils.py b/arranges/src/arranges/utils.py index e1ab48c..350efd8 100644 --- a/arranges/src/arranges/utils.py +++ b/arranges/src/arranges/utils.py @@ -94,3 +94,13 @@ def as_type(cls: Type[T], value: Any) -> T: if isinstance(value, cls): return value return cls(value) + + +def try_hash(obj: Any) -> int | None: + """ + Try to hash an object. If it can't be hashed, return None + """ + try: + return hash(obj) + except TypeError: + return None diff --git a/arranges/tests/ranges/test_combine.py b/arranges/tests/range/test_combine.py similarity index 58% rename from arranges/tests/ranges/test_combine.py rename to arranges/tests/range/test_combine.py index 85b097b..f5b2c45 100644 --- a/arranges/tests/ranges/test_combine.py +++ b/arranges/tests/range/test_combine.py @@ -1,17 +1,17 @@ -from arranges import Range, Ranges +from arranges.range import Range def test_collapse_ranges(): - assert Ranges("0:10, 20:") == Ranges(":0x0a, 0x14:") - assert Ranges(":,:") == Ranges(":") - assert Ranges("1:10, 5:15") == Ranges("1:15") + assert Range("0:10, 20:") == Range(":0x0a, 0x14:") + assert Range(":,:") == Range(":") + assert Range("1:10, 5:15") == Range("1:15") def test_combine_two_ranges(): - first = Ranges("1:10, 20:30") - second = Ranges("0:5, 100:200") + first = Range("1:10, 20:30") + second = Range("0:5, 100:200") combined = first + second - expected = Ranges("0:10,20:30,100:200") + expected = Range("0:10,20:30,100:200") assert first in combined assert second in combined @@ -19,10 +19,10 @@ def test_combine_two_ranges(): def test_combine_ranges_with_single_range(): - first = Ranges("1:10, 20:30") + first = Range("1:10, 20:30") second = Range("0:5") combined = first + second - expected = Ranges("0:10,20:30") + expected = Range("0:10,20:30") assert first in combined assert second in combined @@ -30,10 +30,10 @@ def test_combine_ranges_with_single_range(): def test_combine_ranges_with_string(): - first = Ranges("1:10, 20:30") + first = Range("1:10, 20:30") second = "0:5" combined = first + second - expected = Ranges("0:10,20:30") + expected = Range("0:10,20:30") assert first in combined assert second in combined @@ -41,10 +41,10 @@ def test_combine_ranges_with_string(): def test_combine_ranges_with_overlap(): - first = Ranges("11:15,20:25") - second = Ranges("0:12, 100:") + first = Range("11:15,20:25") + second = Range("0:12, 100:") combined = first + second - expected = Ranges("0:15,20:25,100:") + expected = Range("0:15,20:25,100:") assert first in combined assert second in combined @@ -52,16 +52,16 @@ def test_combine_ranges_with_overlap(): def test_intersects(): - first = Ranges("11:15,20:25") - second = Ranges("0:12, 100:") + first = Range("11:15,20:25") + second = Range("0:12, 100:") assert first.intersects(second) assert second.intersects(first) def test_doesnt_overlap(): - first = Ranges("14") - second = Ranges("15:") + first = Range("14") + second = Range("15:") assert not first.intersects(second) assert not second.intersects(first) diff --git a/arranges/tests/range/test_construct_iterable.py b/arranges/tests/range/test_construct_iterable.py index 577c794..4b47c10 100644 --- a/arranges/tests/range/test_construct_iterable.py +++ b/arranges/tests/range/test_construct_iterable.py @@ -1,6 +1,6 @@ import pytest -from arranges import Range +from arranges.range import Range def test_construct_from_list(): @@ -30,9 +30,10 @@ def gen(): assert actual == expected -def test_no_holes_allowed(): - with pytest.raises(ValueError): - Range([1, 2, 4, 5]) +def test_construct_with_hole(): + expected = "1:3,4:6" + actual = Range([1, 2, 4, 5]) + assert actual == expected def test_duplicates(): diff --git a/arranges/tests/range/test_construct_rangelike.py b/arranges/tests/range/test_construct_rangelike.py index 80f9e94..f4f66fa 100644 --- a/arranges/tests/range/test_construct_rangelike.py +++ b/arranges/tests/range/test_construct_rangelike.py @@ -1,6 +1,4 @@ -import pytest - -from arranges import Range, inf +from arranges.range import Range def test_construct_from_range(): @@ -8,39 +6,21 @@ def test_construct_from_range(): ours = Range(py) assert ours == py - assert ours.start == py.start - assert ours.stop == py.stop - assert ours.step == py.step == 1 + assert ours == "20:30" -def test_construct_from_slice(): +def test_construct_from_slice_step_1(): py = slice(20, 30, 1) ours = Range(py) assert ours == py - assert ours.start == py.start - assert ours.stop == py.stop - assert ours.step == py.step == 1 - - -def test_construct_from_slice_no_step(): - py = slice(20, 30) - ours = Range(py) - - assert ours == py - assert ours.start == py.start - assert ours.stop == py.stop - assert ours.step == 1 + assert ours == "20:30" def test_construct_from_range_with_step(): - with pytest.raises(ValueError): - Range(range(20, 30, 2)) - - -def test_construct_from_slice_with_step(): - with pytest.raises(ValueError): - Range(slice(20, 30, 2)) + actual = Range(range(10, 20, 2)) + expected = "10,12,14,16,18" + assert actual == expected def def_construct_boundless_slice(): @@ -48,5 +28,4 @@ def def_construct_boundless_slice(): ours = Range(py) assert ours == py - assert ours.start == py.start - assert ours.stop == inf + assert ours == "20:" diff --git a/arranges/tests/range/test_construct_str.py b/arranges/tests/range/test_construct_str.py index 4631990..003b787 100644 --- a/arranges/tests/range/test_construct_str.py +++ b/arranges/tests/range/test_construct_str.py @@ -1,16 +1,9 @@ import pytest -from arranges import Range +from arranges.range import Range from arranges.utils import inf -def test_range(): - val = Range("1:2") - - assert val.start == 1 - assert val.stop == 2 - - def test_too_many_values(): with pytest.raises(ValueError): Range("1:2:3") @@ -26,25 +19,25 @@ def test_range_negative_stop(): Range("1:-2") -def test_range_no_start(): +def test_range_no_first(): val = Range(":2") - assert val.start == 0 - assert val.stop == 2 + assert val.first == 0 + assert val.last == 1 -def test_range_no_stop(): +def test_range_no_last(): val = Range("1:") - assert val.start == 1 - assert val.stop == inf + assert val.first == 1 + assert val.last == inf -def test_range_no_start_no_stop(): +def test_range_no_start_no_last(): val = Range(":") - assert val.start == 0 - assert val.stop == inf + assert val.first == 0 + assert val.last == inf def test_hex_range(): diff --git a/arranges/tests/range/test_construct_values.py b/arranges/tests/range/test_construct_values.py index 52aa6c1..8848532 100644 --- a/arranges/tests/range/test_construct_values.py +++ b/arranges/tests/range/test_construct_values.py @@ -6,15 +6,19 @@ def test_start_and_stop(): r = Range(10, 20) - assert r.start == 10 - assert r.stop == 20 + assert r.first == 10 + assert r.last == 19 + + +def test_length_1(): + assert Range(1) == "0" def test_value_with_integer(): r = Range(2) - assert r.start == 0 - assert r.stop == 2 + assert r.first == 0 + assert r.last == 1 def test_range_from_range(): @@ -34,7 +38,6 @@ def test_empty_range(): empty2 = Range(100, 100) assert empty1 == empty2 - assert empty1.start == empty1.stop == empty2.start == empty2.stop == 0 assert empty1 == empty2 == "" diff --git a/arranges/tests/range/test_construction.py b/arranges/tests/range/test_construction.py new file mode 100644 index 0000000..5fb6361 --- /dev/null +++ b/arranges/tests/range/test_construction.py @@ -0,0 +1,37 @@ +import pytest + +from arranges import Range + + +def test_parse_single_range(): + r = Range("1:10") + assert len(r.segments) == 1 + assert r.segments[0].start == 1 + assert r.segments[0].stop == 10 + + +def test_parse_multiple_ranges(): + r = Range("1:10, 20:30") + + assert len(r.segments) == 2 + assert r.segments[0].start == 1 + assert r.segments[0].stop == 10 + assert r.segments[1].start == 20 + assert r.segments[1].stop == 30 + + +def test_from_sequence(): + ranges = Range([1, 2, 3, 4]) + + assert ranges == Range("1:5") + + +def test_from_nested_mess(): + ranges = Range([[1, [2], ["101:201,30:31"], 3], range(10, 15)]) + + assert ranges == Range("1:4,10:15,30:31,101:201") + + +def test_from_unsupported_type(): + with pytest.raises(TypeError): + Range(None) diff --git a/arranges/tests/range/test_contains.py b/arranges/tests/range/test_contains.py index b903701..c303e8b 100644 --- a/arranges/tests/range/test_contains.py +++ b/arranges/tests/range/test_contains.py @@ -1,6 +1,6 @@ import pytest -from arranges import Range +from arranges.range import Range def test_contains_int(): diff --git a/arranges/tests/range/test_equality.py b/arranges/tests/range/test_equality.py index 2b7b5e0..24b4d48 100644 --- a/arranges/tests/range/test_equality.py +++ b/arranges/tests/range/test_equality.py @@ -19,15 +19,15 @@ def test_equality_str_left(): def test_equality_int_right(): - assert Range("0:1") == 0 - assert Range("10:11") == 10 - assert Range("11") == 11 + assert Range("0:1") == [0] + assert Range("10:11") == [10] + assert Range("11") == [11] def test_equality_int_left(): - assert 0 == Range("0:1") - assert 10 == Range("10:11") - assert 11 == Range("11") + assert [0] == Range("0:1") + assert [10] == Range("10:11") + assert [11] == Range("11") def test_equality_range_right(): diff --git a/arranges/tests/range/test_intersection.py b/arranges/tests/range/test_intersection.py index b3466d5..e58fba7 100644 --- a/arranges/tests/range/test_intersection.py +++ b/arranges/tests/range/test_intersection.py @@ -1,4 +1,4 @@ -from arranges.range import Range +from arranges import Range def r(s: str) -> Range: diff --git a/arranges/tests/ranges/test_membership.py b/arranges/tests/range/test_membership.py similarity index 73% rename from arranges/tests/ranges/test_membership.py rename to arranges/tests/range/test_membership.py index cbff9d7..7e0b972 100644 --- a/arranges/tests/ranges/test_membership.py +++ b/arranges/tests/range/test_membership.py @@ -1,8 +1,8 @@ -from arranges import Range, Ranges +from arranges import Range def test_membership_int(): - ranges = Ranges("1:10,20:30,100:150") + ranges = Range("1:10,20:30,100:150") assert 0 not in ranges assert 1 in ranges @@ -17,20 +17,20 @@ def test_membership_int(): def test_membership_sequence(): - ranges = Ranges("1:10,20:30,100:150") + ranges = Range("1:10,20:30,100:150") assert range(10, 15) not in ranges assert range(100, 110) in ranges def test_membership_range(): - ranges = Ranges("1:10,20:30,100:150") + ranges = Range("1:10,20:30,100:150") assert Range(20, 30) in ranges def test_membership_empty_range(): - ranges = Ranges("1:10,20:30,100:150") + ranges = Range("1:10,20:30,100:150") empty = Range(0, 0) assert empty in ranges diff --git a/arranges/tests/range/test_operators.py b/arranges/tests/range/test_operators.py index d198266..688f650 100644 --- a/arranges/tests/range/test_operators.py +++ b/arranges/tests/range/test_operators.py @@ -1,5 +1,3 @@ -import pytest - from arranges import Range @@ -23,8 +21,8 @@ def test_or_operator_empty_range(): def test_or_operator_non_overlapping(): - with pytest.raises(ValueError): - Range("1:10") | Range("11:15") + combined = Range("1:10") | Range("11:15") + assert combined == "1:10,11:15" def test_union_adjacent_ranges(): diff --git a/arranges/tests/range/test_repr.py b/arranges/tests/range/test_repr.py deleted file mode 100644 index 95adde7..0000000 --- a/arranges/tests/range/test_repr.py +++ /dev/null @@ -1,46 +0,0 @@ -from arranges import Range - - -def test_repr_empty(): - assert repr(Range("")) == "Range(0, 0)" - - -def test_repr_full(): - assert repr(Range(":")) == "Range(0, inf)" - - -def test_repr_stop_only(): - assert repr(Range(" 102")) == "Range(102, 103)" - assert repr(Range(":123")) == "Range(123)" - - -def test_repr_start_only(): - assert repr(Range(" 102:")) == "Range(102, inf)" - - -def test_repr_start_and_stop(): - assert repr(Range(" 102 : 123 ")) == "Range(102, 123)" - - -def test_str_empty(): - assert str(Range("")) == "" - - -def test_str_full(): - assert str(Range(":")) == ":" - - -def test_str_stop_only(): - assert str(Range(":123")) == ":123" - - -def test_str_start_only(): - assert str(Range(" 102:")) == "102:" - - -def test_str_single_number(): - assert str(Range(" 102")) == "102" - - -def test_str_start_and_stop(): - assert str(Range(" 102 : 123 ")) == "102:123" diff --git a/arranges/tests/range/test_union.py b/arranges/tests/range/test_union.py index f317272..06d8800 100644 --- a/arranges/tests/range/test_union.py +++ b/arranges/tests/range/test_union.py @@ -21,14 +21,3 @@ def test_intersects(): assert not Range("1:10").intersects(Range("11:15")) assert not Range("1:10").intersects(Range("11:")) - - -def test_isadjacent(): - assert Range("1:10").isadjacent(Range("10:15")) - assert Range("1:10").isadjacent(Range("0:1")) - - -def test_not_adjacent(): - assert not Range("1:10").isadjacent(Range("0:10")) - assert not Range("1:10").isadjacent(Range("11:15")) - assert not Range("1:10").isadjacent(Range("11:")) diff --git a/arranges/tests/range/test_validator.py b/arranges/tests/range/test_validator.py index 89fd670..d78bc61 100644 --- a/arranges/tests/range/test_validator.py +++ b/arranges/tests/range/test_validator.py @@ -14,15 +14,15 @@ class Config: def test_working_range_str(): model = ModelWithRange(range="1:10") - assert model.range.start == 1 - assert model.range.stop == 10 + assert model.range.first == 1 + assert model.range.last == 9 def test_working_range(): model = ModelWithRange(range=Range("1:10")) - assert model.range.start == 1 - assert model.range.stop == 10 + assert model.range.first == 1 + assert model.range.last == 9 def test_invalid_range(): @@ -33,5 +33,5 @@ def test_invalid_range(): def test_validate_range_object(): model = ModelWithRange(range=range(10, 20)) - assert model.range.start == 10 - assert model.range.stop == 20 + assert model.range.first == 10 + assert model.range.last == 19 diff --git a/arranges/tests/ranges/__init__.py b/arranges/tests/ranges/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/arranges/tests/ranges/test_construction.py b/arranges/tests/ranges/test_construction.py deleted file mode 100644 index f515828..0000000 --- a/arranges/tests/ranges/test_construction.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from arranges import Ranges - - -def test_parse_single_range(): - r = Ranges("1:10") - assert len(r.ranges) == 1 - assert r.ranges[0].start == 1 - assert r.ranges[0].stop == 10 - - -def test_parse_multiple_ranges(): - r = Ranges("1:10, 20:30") - - assert len(r.ranges) == 2 - assert r.ranges[0].start == 1 - assert r.ranges[0].stop == 10 - assert r.ranges[1].start == 20 - assert r.ranges[1].stop == 30 - - -def test_from_sequence(): - ranges = Ranges([1, 2, 3, 4]) - - assert ranges == Ranges("1:5") - - -def test_from_nested_mess(): - ranges = Ranges([[1, [2], ["101:201,30:31"], 3], range(10, 15)]) - - assert ranges == Ranges("1:4,10:15,30:31,101:201") - - -def test_from_unsupported_type(): - with pytest.raises(TypeError): - Ranges(None) diff --git a/arranges/tests/ranges/test_equality.py b/arranges/tests/ranges/test_equality.py deleted file mode 100644 index 5ce31bc..0000000 --- a/arranges/tests/ranges/test_equality.py +++ /dev/null @@ -1,10 +0,0 @@ -from arranges import Range, Ranges - - -def test_iterator(): - assert list(Ranges("1:5, 10:15")) == [1, 2, 3, 4, 10, 11, 12, 13, 14] - - -def test_ranges_equal(): - assert Ranges("10") == Range("10:11") - assert Range("0:10") == Ranges(":10") diff --git a/arranges/tests/ranges/test_repr.py b/arranges/tests/ranges/test_repr.py deleted file mode 100644 index 251ae08..0000000 --- a/arranges/tests/ranges/test_repr.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest - -from arranges import Ranges - - -def test_repr(): - ranges = Ranges(" 0x01:0b10, 15:20 , 30:") - assert repr(ranges) == type(ranges).__name__ + '("1,15:20,30:")' - - -def test_str(): - ranges = Ranges(" 0x01:0b10, 15:20 , 30: ") - assert str(ranges) == "1,15:20,30:" - - -def test_hash(): - """ - This is a mutable type so it can't be hashed. - """ - with pytest.raises(TypeError): - hash(Ranges("1:10, 20:30")) diff --git a/arranges/tests/ranges/test_validator.py b/arranges/tests/ranges/test_validator.py deleted file mode 100644 index f10ccc9..0000000 --- a/arranges/tests/ranges/test_validator.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest -from pydantic import BaseModel, ValidationError - -from arranges import Ranges - - -class ModelWithRanges(BaseModel): - ranges: Ranges - - class Config: - arbitrary_types_allowed = True - - -def test_working_ranges_str(): - model = ModelWithRanges(ranges="1:10, 20:30") - - assert len(model.ranges.ranges) == 2 - assert model.ranges.ranges[0].start == 1 - assert model.ranges.ranges[0].stop == 10 - assert model.ranges.ranges[1].start == 20 - assert model.ranges.ranges[1].stop == 30 - - -def test_working_ranges(): - model = ModelWithRanges(ranges=Ranges("1:10, 20:30")) - - assert len(model.ranges.ranges) == 2 - assert model.ranges.ranges[0].start == 1 - assert model.ranges.ranges[0].stop == 10 - assert model.ranges.ranges[1].start == 20 - assert model.ranges.ranges[1].stop == 30 - - -def test_invalid_ranges(): - with pytest.raises(ValidationError): - ModelWithRanges(range="this isn't ranges") - - -def test_some_invalid_ranges(): - with pytest.raises(ValidationError): - ModelWithRanges(range="0:10, boom!, 20:30") diff --git a/arranges/tests/segment/__init__.py b/arranges/tests/segment/__init__.py new file mode 100644 index 0000000..91c2399 --- /dev/null +++ b/arranges/tests/segment/__init__.py @@ -0,0 +1,12 @@ +from arranges.segment import Segment + + +def test_isadjacent(): + assert Segment("1:10").isadjacent(Segment("10:15")) + assert Segment("1:10").isadjacent(Segment("0:1")) + + +def test_not_adjacent(): + assert not Segment("1:10").isadjacent(Segment("0:10")) + assert not Segment("1:10").isadjacent(Segment("11:15")) + assert not Segment("1:10").isadjacent(Segment("11:")) diff --git a/arranges/tests/utils/test_try_hash.py b/arranges/tests/utils/test_try_hash.py new file mode 100644 index 0000000..f246087 --- /dev/null +++ b/arranges/tests/utils/test_try_hash.py @@ -0,0 +1,9 @@ +from arranges.utils import try_hash + + +def test_hashable(): + assert try_hash("hey") == hash("hey") + + +def test_unhashable(): + assert not try_hash({}) From 9d9b2854742304fec73b617b3bc344bd989b3632 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 13:10:34 +0100 Subject: [PATCH 03/11] move tests around, add invert --- arranges/src/arranges/range.py | 39 +++++++- arranges/src/arranges/segment.py | 2 +- arranges/tests/range/construct/__init__.py | 0 .../test_construct_int.py} | 0 .../test_construct_iterable.py | 12 +++ .../test_construct_rangelike.py | 2 +- .../{ => construct}/test_construct_str.py | 20 ++++- arranges/tests/range/construct/test_none.py | 5 ++ arranges/tests/range/operators/__init__.py | 0 .../range/{ => operators}/test_combine.py | 0 .../tests/range/operators/test_contains.py | 90 +++++++++++++++++++ .../range/{ => operators}/test_equality.py | 0 .../tests/range/{ => operators}/test_hash.py | 0 .../{ => operators}/test_intersection.py | 0 arranges/tests/range/operators/test_invert.py | 25 ++++++ .../range/{ => operators}/test_operators.py | 0 .../tests/range/{ => operators}/test_union.py | 0 arranges/tests/range/properties/__init__.py | 0 .../tests/range/{ => properties}/test_last.py | 0 arranges/tests/range/serialize/__init__.py | 0 .../tests/range/serialize/test_serialize.py | 2 + .../range/{ => serialize}/test_validator.py | 0 arranges/tests/range/test_construction.py | 37 -------- arranges/tests/range/test_contains.py | 57 ------------ arranges/tests/range/test_membership.py | 36 -------- 25 files changed, 190 insertions(+), 137 deletions(-) create mode 100644 arranges/tests/range/construct/__init__.py rename arranges/tests/range/{test_construct_values.py => construct/test_construct_int.py} (100%) rename arranges/tests/range/{ => construct}/test_construct_iterable.py (78%) rename arranges/tests/range/{ => construct}/test_construct_rangelike.py (94%) rename arranges/tests/range/{ => construct}/test_construct_str.py (74%) create mode 100644 arranges/tests/range/construct/test_none.py create mode 100644 arranges/tests/range/operators/__init__.py rename arranges/tests/range/{ => operators}/test_combine.py (100%) create mode 100644 arranges/tests/range/operators/test_contains.py rename arranges/tests/range/{ => operators}/test_equality.py (100%) rename arranges/tests/range/{ => operators}/test_hash.py (100%) rename arranges/tests/range/{ => operators}/test_intersection.py (100%) create mode 100644 arranges/tests/range/operators/test_invert.py rename arranges/tests/range/{ => operators}/test_operators.py (100%) rename arranges/tests/range/{ => operators}/test_union.py (100%) create mode 100644 arranges/tests/range/properties/__init__.py rename arranges/tests/range/{ => properties}/test_last.py (100%) create mode 100644 arranges/tests/range/serialize/__init__.py create mode 100644 arranges/tests/range/serialize/test_serialize.py rename arranges/tests/range/{ => serialize}/test_validator.py (100%) delete mode 100644 arranges/tests/range/test_construction.py delete mode 100644 arranges/tests/range/test_contains.py delete mode 100644 arranges/tests/range/test_membership.py diff --git a/arranges/src/arranges/range.py b/arranges/src/arranges/range.py index e379096..dde9a65 100644 --- a/arranges/src/arranges/range.py +++ b/arranges/src/arranges/range.py @@ -3,7 +3,7 @@ from typing import Any, Iterable from arranges.segment import Segment, range_idx -from arranges.utils import is_intlike, is_iterable, is_rangelike, try_hash +from arranges.utils import inf, is_intlike, is_iterable, is_rangelike, try_hash class Range(str): @@ -40,7 +40,7 @@ def construct_str(cls, value, stop) -> str: return Segment(value, stop) if value is None: - raise TypeError("Got 0 arguments, expected 1 or 2") + return "" if is_intlike(value): return Segment(0, value) @@ -165,7 +165,7 @@ def __contains__(self, other: Any) -> bool: Are all of the other ranges in our ranges? """ combined = str(self + other) - return self and combined == self + return self and (combined == self) def __iter__(self): """ @@ -201,6 +201,39 @@ def __or__(self, other: "Range") -> "Range": """ return self.union(other) + def __and__(self, other: "Range") -> "Range": + """ + Return the intersection of this range and the other + """ + segments = [] + for r in self.segments: + for o in other.segments: + if r.intersects(o): + segments.append(r.intersection(o)) + return Range(segments) + + def __invert__(self): + """ + The inverse of this range + """ + if not self: + return Range(":") + + segments = [] + + if self.first > 0: + segments.append(Segment(0, self.first)) + + for i in range(len(self.segments)): + if i == len(self.segments) - 1: + segments.append(Segment(self.segments[i].stop, inf)) + else: + segments.append( + Segment(self.segments[i].stop, self.segments[i + 1].start) + ) + + return Range(segments) + @classmethod def validate(cls, value: Any) -> "Range": """ diff --git a/arranges/src/arranges/segment.py b/arranges/src/arranges/segment.py index c93763f..6207f63 100644 --- a/arranges/src/arranges/segment.py +++ b/arranges/src/arranges/segment.py @@ -8,7 +8,7 @@ def fix_start_stop(start: range_idx, stop: range_idx) -> tuple[range_idx, range_idx]: - start = 0 if start is None else int(start) + start = 0 if start is None else (int(start) if start != inf else inf) stop = inf if stop in (None, inf) else int(stop) if start > stop: diff --git a/arranges/tests/range/construct/__init__.py b/arranges/tests/range/construct/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arranges/tests/range/test_construct_values.py b/arranges/tests/range/construct/test_construct_int.py similarity index 100% rename from arranges/tests/range/test_construct_values.py rename to arranges/tests/range/construct/test_construct_int.py diff --git a/arranges/tests/range/test_construct_iterable.py b/arranges/tests/range/construct/test_construct_iterable.py similarity index 78% rename from arranges/tests/range/test_construct_iterable.py rename to arranges/tests/range/construct/test_construct_iterable.py index 4b47c10..376cdf7 100644 --- a/arranges/tests/range/test_construct_iterable.py +++ b/arranges/tests/range/construct/test_construct_iterable.py @@ -47,3 +47,15 @@ def test_duplicates(): def test_negatives_not_allowed(): with pytest.raises(ValueError): Range([1, 2, 3, -1, 4, 5]) + + +def test_from_sequence(): + ranges = Range([1, 2, 3, 4]) + + assert ranges == Range("1:5") + + +def test_from_nested_mess(): + ranges = Range([[1, [2], ["101:201,30:31"], 3], range(10, 15)]) + + assert ranges == Range("1:4,10:15,30:31,101:201") diff --git a/arranges/tests/range/test_construct_rangelike.py b/arranges/tests/range/construct/test_construct_rangelike.py similarity index 94% rename from arranges/tests/range/test_construct_rangelike.py rename to arranges/tests/range/construct/test_construct_rangelike.py index f4f66fa..7daa796 100644 --- a/arranges/tests/range/test_construct_rangelike.py +++ b/arranges/tests/range/construct/test_construct_rangelike.py @@ -1,4 +1,4 @@ -from arranges.range import Range +from arranges import Range def test_construct_from_range(): diff --git a/arranges/tests/range/test_construct_str.py b/arranges/tests/range/construct/test_construct_str.py similarity index 74% rename from arranges/tests/range/test_construct_str.py rename to arranges/tests/range/construct/test_construct_str.py index 003b787..c597245 100644 --- a/arranges/tests/range/test_construct_str.py +++ b/arranges/tests/range/construct/test_construct_str.py @@ -1,7 +1,6 @@ import pytest -from arranges.range import Range -from arranges.utils import inf +from arranges import Range, inf def test_too_many_values(): @@ -72,3 +71,20 @@ def test_empty_str(): assert empty_range == empty_str_range assert len(empty_range) == len(empty_str_range) == 0 + + +def test_parse_single_range(): + r = Range("1:10") + assert len(r.segments) == 1 + assert r.segments[0].start == 1 + assert r.segments[0].stop == 10 + + +def test_parse_multiple_ranges(): + r = Range("1:10, 20:30") + + assert len(r.segments) == 2 + assert r.segments[0].start == 1 + assert r.segments[0].stop == 10 + assert r.segments[1].start == 20 + assert r.segments[1].stop == 30 diff --git a/arranges/tests/range/construct/test_none.py b/arranges/tests/range/construct/test_none.py new file mode 100644 index 0000000..b735323 --- /dev/null +++ b/arranges/tests/range/construct/test_none.py @@ -0,0 +1,5 @@ +from arranges import Range + + +def test_none(): + Range(None) diff --git a/arranges/tests/range/operators/__init__.py b/arranges/tests/range/operators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arranges/tests/range/test_combine.py b/arranges/tests/range/operators/test_combine.py similarity index 100% rename from arranges/tests/range/test_combine.py rename to arranges/tests/range/operators/test_combine.py diff --git a/arranges/tests/range/operators/test_contains.py b/arranges/tests/range/operators/test_contains.py new file mode 100644 index 0000000..0d77f94 --- /dev/null +++ b/arranges/tests/range/operators/test_contains.py @@ -0,0 +1,90 @@ +import pytest + +from arranges import Range + + +def test_contains_tuple(): + assert (1, 2, 3, 4, 5) in Range("1:6") + + +def test_doesnt_contain_sequence(): + assert (1, 2, 3, 5) not in Range("1:5") + + +def test_contains_range(): + assert Range("1:5") in Range("1:10") + assert Range("2:10") in Range("2:") + + +def test_doesnt_contain_range(): + assert Range(":") not in Range("2:10") + assert Range("1:10") not in Range("1:5") + assert Range("2:") not in Range("2:10") + + +def test_nonetype_not_in_empty_range(): + assert None not in Range(None) + + +def test_nonetype_in_full_range(): + assert None in Range(":") + + +def test_nothing_in_empty(): + assert 1 not in Range("") + assert [] not in Range(0, 0) + assert Range("") not in Range("") + + +def test_contains_text_error(): + with pytest.raises(ValueError): + "hello" in Range(":") + + +def test_contains_unsupported_type_error(): + class Crash: + pass + + with pytest.raises(TypeError): + Crash() in Range(":") + + +def test_contains_int(): + range = Range("1:10,20:30,100:150") + + assert 0 not in range + assert 1 in range + assert 5 in range + assert 10 not in range + assert 19 not in range + assert 20 in range + assert 30 not in range + assert 100 in range + assert 149 in range + assert 150 not in range + + +def test_contains_sequence(): + ranges = Range("1:10,20:30,100:150") + + assert range(10, 15) not in ranges + assert range(100, 110) in ranges + + +def test_contains_ranges(): + ranges = Range("1:10,20:30,100:150") + + assert Range(20, 30) in ranges + + +def test_contains_empty_range(): + ranges = Range("1:10,20:30,100:150") + empty = Range(0, 0) + + assert empty in ranges + + +def test_contains_broken(): + ranges = Range("1:10,20:30,100:150") + + assert [1, 3, 25, 125] in ranges diff --git a/arranges/tests/range/test_equality.py b/arranges/tests/range/operators/test_equality.py similarity index 100% rename from arranges/tests/range/test_equality.py rename to arranges/tests/range/operators/test_equality.py diff --git a/arranges/tests/range/test_hash.py b/arranges/tests/range/operators/test_hash.py similarity index 100% rename from arranges/tests/range/test_hash.py rename to arranges/tests/range/operators/test_hash.py diff --git a/arranges/tests/range/test_intersection.py b/arranges/tests/range/operators/test_intersection.py similarity index 100% rename from arranges/tests/range/test_intersection.py rename to arranges/tests/range/operators/test_intersection.py diff --git a/arranges/tests/range/operators/test_invert.py b/arranges/tests/range/operators/test_invert.py new file mode 100644 index 0000000..7ae9ac3 --- /dev/null +++ b/arranges/tests/range/operators/test_invert.py @@ -0,0 +1,25 @@ +from arranges import Range + + +def test_inverted_empty_is_full(): + actual = ~Range("") + expected = Range(":") + assert actual == expected + + +def test_inverted_full_is_empty(): + actual = ~Range(":") + expected = Range("") + assert actual == expected + + +def test_inverted_range(): + actual = ~Range("5:10") + expected = Range(":5,10:") + assert actual == expected + + +def test_inverted_complex_range(): + actual = ~Range("2,4,6,8:50") + expected = Range(":2,3,5,7,50:") + assert actual == expected diff --git a/arranges/tests/range/test_operators.py b/arranges/tests/range/operators/test_operators.py similarity index 100% rename from arranges/tests/range/test_operators.py rename to arranges/tests/range/operators/test_operators.py diff --git a/arranges/tests/range/test_union.py b/arranges/tests/range/operators/test_union.py similarity index 100% rename from arranges/tests/range/test_union.py rename to arranges/tests/range/operators/test_union.py diff --git a/arranges/tests/range/properties/__init__.py b/arranges/tests/range/properties/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arranges/tests/range/test_last.py b/arranges/tests/range/properties/test_last.py similarity index 100% rename from arranges/tests/range/test_last.py rename to arranges/tests/range/properties/test_last.py diff --git a/arranges/tests/range/serialize/__init__.py b/arranges/tests/range/serialize/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arranges/tests/range/serialize/test_serialize.py b/arranges/tests/range/serialize/test_serialize.py new file mode 100644 index 0000000..6034e09 --- /dev/null +++ b/arranges/tests/range/serialize/test_serialize.py @@ -0,0 +1,2 @@ +def test_serialize(): + raise NotImplementedError() diff --git a/arranges/tests/range/test_validator.py b/arranges/tests/range/serialize/test_validator.py similarity index 100% rename from arranges/tests/range/test_validator.py rename to arranges/tests/range/serialize/test_validator.py diff --git a/arranges/tests/range/test_construction.py b/arranges/tests/range/test_construction.py deleted file mode 100644 index 5fb6361..0000000 --- a/arranges/tests/range/test_construction.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from arranges import Range - - -def test_parse_single_range(): - r = Range("1:10") - assert len(r.segments) == 1 - assert r.segments[0].start == 1 - assert r.segments[0].stop == 10 - - -def test_parse_multiple_ranges(): - r = Range("1:10, 20:30") - - assert len(r.segments) == 2 - assert r.segments[0].start == 1 - assert r.segments[0].stop == 10 - assert r.segments[1].start == 20 - assert r.segments[1].stop == 30 - - -def test_from_sequence(): - ranges = Range([1, 2, 3, 4]) - - assert ranges == Range("1:5") - - -def test_from_nested_mess(): - ranges = Range([[1, [2], ["101:201,30:31"], 3], range(10, 15)]) - - assert ranges == Range("1:4,10:15,30:31,101:201") - - -def test_from_unsupported_type(): - with pytest.raises(TypeError): - Range(None) diff --git a/arranges/tests/range/test_contains.py b/arranges/tests/range/test_contains.py deleted file mode 100644 index c303e8b..0000000 --- a/arranges/tests/range/test_contains.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest - -from arranges.range import Range - - -def test_contains_int(): - assert 1 in Range("1:10") - assert 9 in Range("1:10") - assert 5 in Range("1:10") - - -def test_doesnt_contain_int(): - assert 0 not in Range("1:10") - assert 10 not in Range("1:10") - - -def test_contains_sequence(): - assert (1, 2, 3, 4, 5) in Range("1:6") - - -def test_doesnt_contain_sequence(): - assert (1, 2, 3, 4, 5) not in Range("1:5") - - -def test_contains_range(): - assert Range("1:5") in Range("1:10") - assert Range("2:10") in Range("2:") - - -def test_doesnt_contain_range(): - assert Range(":") not in Range("2:10") - assert Range("1:10") not in Range("1:5") - assert Range("2:") not in Range("2:10") - - -def test_nonetype_causes_error(): - with pytest.raises(TypeError): - None in Range(":") - - -def test_nothing_in_empty(): - assert 1 not in Range("") - assert [] not in Range(0, 0) - assert Range("") not in Range("") - - -def test_contains_text_error(): - with pytest.raises(ValueError): - "hello" in Range(":") - - -def test_contains_unsupported_type_error(): - class Crash: - pass - - with pytest.raises(TypeError): - Crash() in Range(":") diff --git a/arranges/tests/range/test_membership.py b/arranges/tests/range/test_membership.py deleted file mode 100644 index 7e0b972..0000000 --- a/arranges/tests/range/test_membership.py +++ /dev/null @@ -1,36 +0,0 @@ -from arranges import Range - - -def test_membership_int(): - ranges = Range("1:10,20:30,100:150") - - assert 0 not in ranges - assert 1 in ranges - assert 5 in ranges - assert 10 not in ranges - assert 19 not in ranges - assert 20 in ranges - assert 30 not in ranges - assert 100 in ranges - assert 149 in ranges - assert 150 not in ranges - - -def test_membership_sequence(): - ranges = Range("1:10,20:30,100:150") - - assert range(10, 15) not in ranges - assert range(100, 110) in ranges - - -def test_membership_range(): - ranges = Range("1:10,20:30,100:150") - - assert Range(20, 30) in ranges - - -def test_membership_empty_range(): - ranges = Range("1:10,20:30,100:150") - empty = Range(0, 0) - - assert empty in ranges From 12f838d52486d4185065984ba5eb9bed925aeca4 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 13:23:51 +0100 Subject: [PATCH 04/11] remove union method --- arranges/src/arranges/range.py | 2 +- arranges/src/arranges/segment.py | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/arranges/src/arranges/range.py b/arranges/src/arranges/range.py index dde9a65..2556d8f 100644 --- a/arranges/src/arranges/range.py +++ b/arranges/src/arranges/range.py @@ -138,7 +138,7 @@ def from_iterable(cls, iterable: Iterable) -> tuple[Segment]: current = segments[i] last = segments[i - 1] if last.isconnected(current): - segments[i - 1] = current.union(last) + segments[i - 1] = current | last del segments[i] i -= 1 i += 1 diff --git a/arranges/src/arranges/segment.py b/arranges/src/arranges/segment.py index 6207f63..8c3fc3a 100644 --- a/arranges/src/arranges/segment.py +++ b/arranges/src/arranges/segment.py @@ -68,6 +68,7 @@ def __or__(self, other: "Segment") -> "Segment": """ if not other: return self + if not self: return other @@ -173,15 +174,6 @@ def __eq__(self, other: Any) -> bool: except (TypeError, ValueError): return False - def union(self, *others) -> "Segment": - """ - Return the union of this segment and the others - """ - ret = self - for other in others: - ret = ret | other - return ret - def isconnected(self, other: "Segment") -> bool: """ True if this range is adjacent to or overlaps the other range, and so they From 15da29495523571694a568ec46686e78f37c57bf Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 20:13:09 +0100 Subject: [PATCH 05/11] rename back to Ranges. add serialization test --- arranges/README.md | 34 +++++++---- arranges/src/arranges/__init__.py | 2 +- arranges/src/arranges/{range.py => ranges.py} | 47 ++++++++------- .../range/construct/test_construct_int.py | 30 +++++----- .../construct/test_construct_iterable.py | 22 +++---- .../construct/test_construct_rangelike.py | 10 ++-- .../range/construct/test_construct_str.py | 34 +++++------ arranges/tests/range/construct/test_none.py | 4 +- .../tests/range/operators/test_combine.py | 38 ++++++------ .../tests/range/operators/test_contains.py | 46 +++++++------- .../tests/range/operators/test_equality.py | 60 +++++++++---------- arranges/tests/range/operators/test_hash.py | 8 +-- .../range/operators/test_intersection.py | 6 +- arranges/tests/range/operators/test_invert.py | 18 +++--- .../tests/range/operators/test_operators.py | 20 +++---- arranges/tests/range/operators/test_union.py | 24 ++++---- arranges/tests/range/properties/test_last.py | 10 ++-- .../tests/range/serialize/test_serialize.py | 19 +++++- .../tests/range/serialize/test_validator.py | 6 +- 19 files changed, 236 insertions(+), 202 deletions(-) rename arranges/src/arranges/{range.py => ranges.py} (87%) diff --git a/arranges/README.md b/arranges/README.md index 1fbef0c..985bf68 100644 --- a/arranges/README.md +++ b/arranges/README.md @@ -8,16 +8,25 @@ type. The reason for this is so the machine-generated command line help is flat and readable by humans. It it kinda grew into a monster so I've split it out into this separate -package. It gives a couple of classes for dealing with ranges: - -* `Range`, a class that can be constructed from Python-style slice notation - strings (e.g. `"1:10"`, `"0x00:0xff`, `":"`), range-likes, iterables of - int-like objects. It has convenient properties lke being iterable, immutable, - has a stable string representation and matching hash, it can be treated - like a `set` and its constructor is compatible with `range` and `slice`. -* The `Ranges` class is similar but supports holes in ranges - it's an ordered, - mutable list of non-overlapping `Range` objects that simplifies as data is - added. +package. The main feature is a pair of classes that can represent ranges: + +* `Segment` is a class that can be treated like a `set` and its constructor is + compatible with `range` and `slice`. It is derived from `str` so serializes + without custom JSON encoders. It is immutable, hashable and has a stable + string representation. +* `Ranges` is an ordered `tuple` of `Segment`s. It is also immutable and + derived from `str` so serializes without custom JSON encoders like the above. + It can be constructed from comma-separated Python-style slice notation strings + (e.g. `"1:10, 20:"`, `"0x00:0xff` and `":"`), integers, `slice`s, `range`s, + integers and (nested) iterables of the above. +* An `inf` singleton that is a `float` with a value of `math.inf` but has an + `__index__` that returns `sys.maxsize` and compares equal to infinity and + `maxsize`, and its string representation is `"inf"`. + +The range classes are designed to be used as fields in Pydantic `BaseModel`s, +but they can be used anywhere you need a range. They are not designed with +speed in mind, and comparisons usually use the canonical string form by +converting other things into `Ranges` objects. ## Constraints @@ -26,8 +35,9 @@ I made it to select lines or bytes in a stream of data, so it: * only supports `int`s; * does not allow negative indices, the minimum is 0 and the maximum is unbounded; -* it's compatible with `range` and `slice`, but `step` is fixed to `1`. This - may change in the future; +* it's compatible with `range` and `slice`, but `step` is fixed to `1`. If + you pass something with a step into its constructor it'll be converted to + a list of `int`s (`range(0, 10, 2)` becomes `"0,2,4,6,8"`); * does not support duplicate ranges. Ranges are merged together as they are added to the `Ranges` object; * it is unpydantic in that its constructors are duck-typed, which is what I diff --git a/arranges/src/arranges/__init__.py b/arranges/src/arranges/__init__.py index 25b7911..aadfe41 100644 --- a/arranges/src/arranges/__init__.py +++ b/arranges/src/arranges/__init__.py @@ -1,3 +1,3 @@ -from arranges.range import Range # noqa +from arranges.ranges import Ranges # noqa from arranges.segment import Segment # noqa from arranges.utils import inf # noqa diff --git a/arranges/src/arranges/range.py b/arranges/src/arranges/ranges.py similarity index 87% rename from arranges/src/arranges/range.py rename to arranges/src/arranges/ranges.py index 2556d8f..42e7bcb 100644 --- a/arranges/src/arranges/range.py +++ b/arranges/src/arranges/ranges.py @@ -6,13 +6,12 @@ from arranges.utils import inf, is_intlike, is_iterable, is_rangelike, try_hash -class Range(str): +class Ranges(str): """ A range set that can be hashed and converted to a string. """ segments: tuple[Segment] - step: int = 1 def __init__(self, value: Any, stop: range_idx | None = None): """ @@ -37,7 +36,10 @@ def construct_str(cls, value, stop) -> str: stop_only = value is None and stop is not None if start_and_stop or stop_only: - return Segment(value, stop) + seg = Segment(value, stop) + print("ugh", value, stop, "equals", seg) + + return seg if value is None: return "" @@ -88,6 +90,7 @@ def iterable_to_str(cls, iterable: Iterable) -> str: Convert an iterable of ranges to a string """ hashable = tuple(iterable) + # contents might not be hashable if try_hash(hashable): vals = cls.from_hashable_iterable(hashable) else: @@ -111,17 +114,17 @@ def _flatten(iterable: Iterable) -> Iterable[Segment]: for item in iterable: if isinstance(item, Segment): yield item - if isinstance(item, Range): + if isinstance(item, Ranges): yield from item.segments elif isinstance(item, str): if item: - yield from [Segment.from_str(s) for s in Range.split_str(item)] + yield from [Segment.from_str(s) for s in Ranges.split_str(item)] elif is_iterable(item): - yield from Range._flatten(item) + yield from Ranges._flatten(item) elif is_intlike(item): yield Segment(item, item + 1) else: - yield from Range(item).segments + yield from Ranges(item).segments @classmethod def from_iterable(cls, iterable: Iterable) -> tuple[Segment]: @@ -149,15 +152,15 @@ def __hash__(self): return super().__hash__() def __add__(self, other): - s = self.iterable_to_str(tuple([self, other])) - return Range(s) + s = self.iterable_to_str((self, [other])) + return Ranges(s) def __eq__(self, other: Any) -> bool: """ Compare the two lists based on their string representations """ - if not isinstance(other, Range): - other = Range(other) + if not isinstance(other, Ranges): + other = Ranges(other) return super().__eq__(other) def __contains__(self, other: Any) -> bool: @@ -165,6 +168,10 @@ def __contains__(self, other: Any) -> bool: Are all of the other ranges in our ranges? """ combined = str(self + other) + print("combined: ", combined) + print("self:", self) + print("other:", other) + return self and (combined == self) def __iter__(self): @@ -181,7 +188,7 @@ def intersects(self, other: Any) -> bool: """ True if this range overlaps with the other range """ - other: Range = Range(other) + other: Ranges = Ranges(other) for r in self.segments: for o in other.segments: if r.intersects(o): @@ -189,19 +196,19 @@ def intersects(self, other: Any) -> bool: return False - def union(self, other) -> "Range": + def union(self, other) -> "Ranges": """ Return the union of this range and the other """ - return Range(self + other) + return Ranges(self + other) - def __or__(self, other: "Range") -> "Range": + def __or__(self, other: "Ranges") -> "Ranges": """ Return the union of this range and the other """ return self.union(other) - def __and__(self, other: "Range") -> "Range": + def __and__(self, other: "Ranges") -> "Ranges": """ Return the intersection of this range and the other """ @@ -210,14 +217,14 @@ def __and__(self, other: "Range") -> "Range": for o in other.segments: if r.intersects(o): segments.append(r.intersection(o)) - return Range(segments) + return Ranges(segments) def __invert__(self): """ The inverse of this range """ if not self: - return Range(":") + return Ranges(":") segments = [] @@ -232,10 +239,10 @@ def __invert__(self): Segment(self.segments[i].stop, self.segments[i + 1].start) ) - return Range(segments) + return Ranges(segments) @classmethod - def validate(cls, value: Any) -> "Range": + def validate(cls, value: Any) -> "Ranges": """ Validate a value and convert it to a Range """ diff --git a/arranges/tests/range/construct/test_construct_int.py b/arranges/tests/range/construct/test_construct_int.py index 8848532..92154fa 100644 --- a/arranges/tests/range/construct/test_construct_int.py +++ b/arranges/tests/range/construct/test_construct_int.py @@ -1,41 +1,41 @@ import pytest -from arranges import Range +from arranges import Ranges def test_start_and_stop(): - r = Range(10, 20) + r = Ranges(10, 20) assert r.first == 10 assert r.last == 19 def test_length_1(): - assert Range(1) == "0" + assert Ranges(1) == "0" def test_value_with_integer(): - r = Range(2) + r = Ranges(2) assert r.first == 0 assert r.last == 1 def test_range_from_range(): - first = Range(1) - second = Range(first) + first = Ranges(1) + second = Ranges(first) assert first == second def test_error_on_empty_args(): with pytest.raises(TypeError): - Range() + Ranges() def test_empty_range(): - empty1 = Range(0, 0) - empty2 = Range(100, 100) + empty1 = Ranges(0, 0) + empty2 = Ranges(100, 100) assert empty1 == empty2 assert empty1 == empty2 == "" @@ -43,19 +43,19 @@ def test_empty_range(): def test_range_with_negative_start(): with pytest.raises(ValueError): - Range(-1, 2) + Ranges(-1, 2) def test_range_with_negative_stop(): with pytest.raises(ValueError): - Range(1, -2) + Ranges(1, -2) def test_same_behaviour_as_range(): - assert range(10) == Range(10) - assert Range(1, 2) == range(1, 2) + assert range(10) == Ranges(10) + assert Ranges(1, 2) == range(1, 2) def test_same_behaviour_as_slice(): - assert slice(10) == Range(10) - assert Range(1, 2) == slice(1, 2) + assert slice(10) == Ranges(10) + assert Ranges(1, 2) == slice(1, 2) diff --git a/arranges/tests/range/construct/test_construct_iterable.py b/arranges/tests/range/construct/test_construct_iterable.py index 376cdf7..9f3012c 100644 --- a/arranges/tests/range/construct/test_construct_iterable.py +++ b/arranges/tests/range/construct/test_construct_iterable.py @@ -1,11 +1,11 @@ import pytest -from arranges.range import Range +from arranges.ranges import Ranges def test_construct_from_list(): expected = [1, 2, 3, 4, 5] - actual = Range(expected) + actual = Ranges(expected) assert actual == expected assert actual == "1:6" @@ -13,7 +13,7 @@ def test_construct_from_list(): def test_construct_from_tuple(): expected = (1, 2, 3, 4, 5) - actual = Range(expected) + actual = Ranges(expected) assert actual == expected assert actual == "1:6" @@ -25,20 +25,20 @@ def gen(): yield i expected = list(gen()) - actual = Range(gen()) + actual = Ranges(gen()) assert actual == expected def test_construct_with_hole(): expected = "1:3,4:6" - actual = Range([1, 2, 4, 5]) + actual = Ranges([1, 2, 4, 5]) assert actual == expected def test_duplicates(): expected = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4] - actual = Range(expected) + actual = Ranges(expected) assert actual == expected assert actual == "1:5" @@ -46,16 +46,16 @@ def test_duplicates(): def test_negatives_not_allowed(): with pytest.raises(ValueError): - Range([1, 2, 3, -1, 4, 5]) + Ranges([1, 2, 3, -1, 4, 5]) def test_from_sequence(): - ranges = Range([1, 2, 3, 4]) + ranges = Ranges([1, 2, 3, 4]) - assert ranges == Range("1:5") + assert ranges == Ranges("1:5") def test_from_nested_mess(): - ranges = Range([[1, [2], ["101:201,30:31"], 3], range(10, 15)]) + ranges = Ranges([[1, [2], ["101:201,30:31"], 3], range(10, 15)]) - assert ranges == Range("1:4,10:15,30:31,101:201") + assert ranges == Ranges("1:4,10:15,30:31,101:201") diff --git a/arranges/tests/range/construct/test_construct_rangelike.py b/arranges/tests/range/construct/test_construct_rangelike.py index 7daa796..3363fd0 100644 --- a/arranges/tests/range/construct/test_construct_rangelike.py +++ b/arranges/tests/range/construct/test_construct_rangelike.py @@ -1,9 +1,9 @@ -from arranges import Range +from arranges import Ranges def test_construct_from_range(): py = range(20, 30) - ours = Range(py) + ours = Ranges(py) assert ours == py assert ours == "20:30" @@ -11,21 +11,21 @@ def test_construct_from_range(): def test_construct_from_slice_step_1(): py = slice(20, 30, 1) - ours = Range(py) + ours = Ranges(py) assert ours == py assert ours == "20:30" def test_construct_from_range_with_step(): - actual = Range(range(10, 20, 2)) + actual = Ranges(range(10, 20, 2)) expected = "10,12,14,16,18" assert actual == expected def def_construct_boundless_slice(): py = slice(20, None) - ours = Range(py) + ours = Ranges(py) assert ours == py assert ours == "20:" diff --git a/arranges/tests/range/construct/test_construct_str.py b/arranges/tests/range/construct/test_construct_str.py index c597245..a18a028 100644 --- a/arranges/tests/range/construct/test_construct_str.py +++ b/arranges/tests/range/construct/test_construct_str.py @@ -1,87 +1,87 @@ import pytest -from arranges import Range, inf +from arranges import Ranges, inf def test_too_many_values(): with pytest.raises(ValueError): - Range("1:2:3") + Ranges("1:2:3") def test_range_negative_start(): with pytest.raises(ValueError): - Range("-1:2") + Ranges("-1:2") def test_range_negative_stop(): with pytest.raises(ValueError): - Range("1:-2") + Ranges("1:-2") def test_range_no_first(): - val = Range(":2") + val = Ranges(":2") assert val.first == 0 assert val.last == 1 def test_range_no_last(): - val = Range("1:") + val = Ranges("1:") assert val.first == 1 assert val.last == inf def test_range_no_start_no_last(): - val = Range(":") + val = Ranges(":") assert val.first == 0 assert val.last == inf def test_hex_range(): - assert Range("0x1:0x10") == Range("1:16") + assert Ranges("0x1:0x10") == Ranges("1:16") def test_binary_range(): - assert Range("0b0:0b100") == Range(":4") + assert Ranges("0b0:0b100") == Ranges(":4") def test_octal_range(): - assert Range("0o0:0o10") == Range(":8") + assert Ranges("0o0:0o10") == Ranges(":8") def test_invalid_ints(): with pytest.raises(ValueError): - Range("bleep:bloop") + Ranges("bleep:bloop") def test_full_range(): - assert Range("0:inf") == Range(":") + assert Ranges("0:inf") == Ranges(":") def test_start_after_stop(): with pytest.raises(ValueError): - Range("10:1") + Ranges("10:1") def test_empty_str(): - empty_str_range = Range("") - empty_range = Range(0, 0) + empty_str_range = Ranges("") + empty_range = Ranges(0, 0) assert empty_range == empty_str_range assert len(empty_range) == len(empty_str_range) == 0 def test_parse_single_range(): - r = Range("1:10") + r = Ranges("1:10") assert len(r.segments) == 1 assert r.segments[0].start == 1 assert r.segments[0].stop == 10 def test_parse_multiple_ranges(): - r = Range("1:10, 20:30") + r = Ranges("1:10, 20:30") assert len(r.segments) == 2 assert r.segments[0].start == 1 diff --git a/arranges/tests/range/construct/test_none.py b/arranges/tests/range/construct/test_none.py index b735323..5ff7781 100644 --- a/arranges/tests/range/construct/test_none.py +++ b/arranges/tests/range/construct/test_none.py @@ -1,5 +1,5 @@ -from arranges import Range +from arranges import Ranges def test_none(): - Range(None) + Ranges(None) diff --git a/arranges/tests/range/operators/test_combine.py b/arranges/tests/range/operators/test_combine.py index f5b2c45..f3aae67 100644 --- a/arranges/tests/range/operators/test_combine.py +++ b/arranges/tests/range/operators/test_combine.py @@ -1,17 +1,17 @@ -from arranges.range import Range +from arranges.ranges import Ranges def test_collapse_ranges(): - assert Range("0:10, 20:") == Range(":0x0a, 0x14:") - assert Range(":,:") == Range(":") - assert Range("1:10, 5:15") == Range("1:15") + assert Ranges("0:10, 20:") == Ranges(":0x0a, 0x14:") + assert Ranges(":,:") == Ranges(":") + assert Ranges("1:10, 5:15") == Ranges("1:15") def test_combine_two_ranges(): - first = Range("1:10, 20:30") - second = Range("0:5, 100:200") + first = Ranges("1:10, 20:30") + second = Ranges("0:5, 100:200") combined = first + second - expected = Range("0:10,20:30,100:200") + expected = Ranges("0:10,20:30,100:200") assert first in combined assert second in combined @@ -19,10 +19,10 @@ def test_combine_two_ranges(): def test_combine_ranges_with_single_range(): - first = Range("1:10, 20:30") - second = Range("0:5") + first = Ranges("1:10, 20:30") + second = Ranges("0:5") combined = first + second - expected = Range("0:10,20:30") + expected = Ranges("0:10,20:30") assert first in combined assert second in combined @@ -30,10 +30,10 @@ def test_combine_ranges_with_single_range(): def test_combine_ranges_with_string(): - first = Range("1:10, 20:30") + first = Ranges("1:10, 20:30") second = "0:5" combined = first + second - expected = Range("0:10,20:30") + expected = Ranges("0:10,20:30") assert first in combined assert second in combined @@ -41,10 +41,10 @@ def test_combine_ranges_with_string(): def test_combine_ranges_with_overlap(): - first = Range("11:15,20:25") - second = Range("0:12, 100:") + first = Ranges("11:15,20:25") + second = Ranges("0:12, 100:") combined = first + second - expected = Range("0:15,20:25,100:") + expected = Ranges("0:15,20:25,100:") assert first in combined assert second in combined @@ -52,16 +52,16 @@ def test_combine_ranges_with_overlap(): def test_intersects(): - first = Range("11:15,20:25") - second = Range("0:12, 100:") + first = Ranges("11:15,20:25") + second = Ranges("0:12, 100:") assert first.intersects(second) assert second.intersects(first) def test_doesnt_overlap(): - first = Range("14") - second = Range("15:") + first = Ranges("14") + second = Ranges("15:") assert not first.intersects(second) assert not second.intersects(first) diff --git a/arranges/tests/range/operators/test_contains.py b/arranges/tests/range/operators/test_contains.py index 0d77f94..8f4b3a0 100644 --- a/arranges/tests/range/operators/test_contains.py +++ b/arranges/tests/range/operators/test_contains.py @@ -1,44 +1,44 @@ import pytest -from arranges import Range +from arranges import Ranges def test_contains_tuple(): - assert (1, 2, 3, 4, 5) in Range("1:6") + assert (1, 2, 3, 4, 5) in Ranges("1:6") def test_doesnt_contain_sequence(): - assert (1, 2, 3, 5) not in Range("1:5") + assert (1, 2, 3, 5) not in Ranges("1:5") def test_contains_range(): - assert Range("1:5") in Range("1:10") - assert Range("2:10") in Range("2:") + assert Ranges("1:5") in Ranges("1:10") + assert Ranges("2:10") in Ranges("2:") def test_doesnt_contain_range(): - assert Range(":") not in Range("2:10") - assert Range("1:10") not in Range("1:5") - assert Range("2:") not in Range("2:10") + assert Ranges(":") not in Ranges("2:10") + assert Ranges("1:10") not in Ranges("1:5") + assert Ranges("2:") not in Ranges("2:10") def test_nonetype_not_in_empty_range(): - assert None not in Range(None) + assert None not in Ranges(None) def test_nonetype_in_full_range(): - assert None in Range(":") + assert None in Ranges(":") def test_nothing_in_empty(): - assert 1 not in Range("") - assert [] not in Range(0, 0) - assert Range("") not in Range("") + assert 1 not in Ranges("") + assert [] not in Ranges(0, 0) + assert Ranges("") not in Ranges("") def test_contains_text_error(): with pytest.raises(ValueError): - "hello" in Range(":") + "hello" in Ranges(":") def test_contains_unsupported_type_error(): @@ -46,11 +46,11 @@ class Crash: pass with pytest.raises(TypeError): - Crash() in Range(":") + Crash() in Ranges(":") def test_contains_int(): - range = Range("1:10,20:30,100:150") + range = Ranges("1:10,20:30,100:150") assert 0 not in range assert 1 in range @@ -65,26 +65,26 @@ def test_contains_int(): def test_contains_sequence(): - ranges = Range("1:10,20:30,100:150") + ranges = Ranges("1:10,20:30,100:150") assert range(10, 15) not in ranges assert range(100, 110) in ranges def test_contains_ranges(): - ranges = Range("1:10,20:30,100:150") + ranges = Ranges("1:10,20:30,100:150") - assert Range(20, 30) in ranges + assert Ranges(20, 30) in ranges def test_contains_empty_range(): - ranges = Range("1:10,20:30,100:150") - empty = Range(0, 0) + ranges = Ranges("1:10,20:30,100:150") + empty = Ranges(0, 0) assert empty in ranges -def test_contains_broken(): - ranges = Range("1:10,20:30,100:150") +def test_contains_split_range(): + ranges = Ranges("1:10,20:30,100:150") assert [1, 3, 25, 125] in ranges diff --git a/arranges/tests/range/operators/test_equality.py b/arranges/tests/range/operators/test_equality.py index 24b4d48..65adba7 100644 --- a/arranges/tests/range/operators/test_equality.py +++ b/arranges/tests/range/operators/test_equality.py @@ -1,66 +1,66 @@ -from arranges import Range, inf +from arranges import Ranges, inf def test_equality(): - assert Range(":") == Range("0:") - assert Range(":10") == Range("0:10") + assert Ranges(":") == Ranges("0:") + assert Ranges(":10") == Ranges("0:10") def test_equality_str_right(): - assert Range("") == "" - assert Range(":") != "" - assert Range(":10") == "0000:00010" + assert Ranges("") == "" + assert Ranges(":") != "" + assert Ranges(":10") == "0000:00010" def test_equality_str_left(): - assert "" == Range(0, 0) - assert "0:10" == Range(0, 10) - assert ":10" == Range(0, 10) + assert "" == Ranges(0, 0) + assert "0:10" == Ranges(0, 10) + assert ":10" == Ranges(0, 10) def test_equality_int_right(): - assert Range("0:1") == [0] - assert Range("10:11") == [10] - assert Range("11") == [11] + assert Ranges("0:1") == [0] + assert Ranges("10:11") == [10] + assert Ranges("11") == [11] def test_equality_int_left(): - assert [0] == Range("0:1") - assert [10] == Range("10:11") - assert [11] == Range("11") + assert [0] == Ranges("0:1") + assert [10] == Ranges("10:11") + assert [11] == Ranges("11") def test_equality_range_right(): - assert Range("0:1") == range(0, 1) - assert Range("") == range(0, 0) - assert Range(":10") == range(0, 10) + assert Ranges("0:1") == range(0, 1) + assert Ranges("") == range(0, 0) + assert Ranges(":10") == range(0, 10) def test_equality_range_left(): - assert range(0, 1) == Range("0:1") - assert range(0, 0) == Range("") - assert range(0, 10) == Range(":10") + assert range(0, 1) == Ranges("0:1") + assert range(0, 0) == Ranges("") + assert range(0, 10) == Ranges(":10") def test_equality_slice_right(): - assert Range(100, inf) == slice(100, None) - assert Range(0, 10) == slice(0, 10) + assert Ranges(100, inf) == slice(100, None) + assert Ranges(0, 10) == slice(0, 10) def test_equality_slice_left(): - assert slice(100, None) == Range(100, inf) - assert slice(0, 10) == Range(0, 10) + assert slice(100, None) == Ranges(100, inf) + assert slice(0, 10) == Ranges(0, 10) def test_equality_with_step(): - assert range(10, 20, 2) != Range("10:20") - assert Range("10:20") != range(10, 20, 2) - assert range(10, 20, 1) == Range("10:20") - assert Range("10:20") == range(10, 20, 1) + assert range(10, 20, 2) != Ranges("10:20") + assert Ranges("10:20") != range(10, 20, 2) + assert range(10, 20, 1) == Ranges("10:20") + assert Ranges("10:20") == range(10, 20, 1) def test_not_equal_to_unknown_type(): class Unknown: pass - assert Range(":") != Unknown() + assert Ranges(":") != Unknown() diff --git a/arranges/tests/range/operators/test_hash.py b/arranges/tests/range/operators/test_hash.py index b121d3a..a51bc43 100644 --- a/arranges/tests/range/operators/test_hash.py +++ b/arranges/tests/range/operators/test_hash.py @@ -1,13 +1,13 @@ -from arranges import Range +from arranges import Ranges def test_full_range(): - assert hash(Range(":")) == hash(Range("0:inf")) + assert hash(Ranges(":")) == hash(Ranges("0:inf")) def test_empty_range(): - assert hash(Range("")) == hash(Range(0, 0)) + assert hash(Ranges("")) == hash(Ranges(0, 0)) def test_hash_is_str_hash(): - assert hash(Range(":10")) == hash(":10") + assert hash(Ranges(":10")) == hash(":10") diff --git a/arranges/tests/range/operators/test_intersection.py b/arranges/tests/range/operators/test_intersection.py index e58fba7..33e723b 100644 --- a/arranges/tests/range/operators/test_intersection.py +++ b/arranges/tests/range/operators/test_intersection.py @@ -1,11 +1,11 @@ -from arranges import Range +from arranges import Ranges -def r(s: str) -> Range: +def r(s: str) -> Ranges: """ Make a Range from a string. """ - return Range([i for i, c in enumerate(s) if c != " "]) + return Ranges([i for i, c in enumerate(s) if c != " "]) def test_left_overlap(): diff --git a/arranges/tests/range/operators/test_invert.py b/arranges/tests/range/operators/test_invert.py index 7ae9ac3..8354a41 100644 --- a/arranges/tests/range/operators/test_invert.py +++ b/arranges/tests/range/operators/test_invert.py @@ -1,25 +1,25 @@ -from arranges import Range +from arranges import Ranges def test_inverted_empty_is_full(): - actual = ~Range("") - expected = Range(":") + actual = ~Ranges("") + expected = Ranges(":") assert actual == expected def test_inverted_full_is_empty(): - actual = ~Range(":") - expected = Range("") + actual = ~Ranges(":") + expected = Ranges("") assert actual == expected def test_inverted_range(): - actual = ~Range("5:10") - expected = Range(":5,10:") + actual = ~Ranges("5:10") + expected = Ranges(":5,10:") assert actual == expected def test_inverted_complex_range(): - actual = ~Range("2,4,6,8:50") - expected = Range(":2,3,5,7,50:") + actual = ~Ranges("2,4,6,8:50") + expected = Ranges(":2,3,5,7,50:") assert actual == expected diff --git a/arranges/tests/range/operators/test_operators.py b/arranges/tests/range/operators/test_operators.py index 688f650..566d75f 100644 --- a/arranges/tests/range/operators/test_operators.py +++ b/arranges/tests/range/operators/test_operators.py @@ -1,17 +1,17 @@ -from arranges import Range +from arranges import Ranges def test_or_operator(): """ Like with a set, or means union """ - assert Range("1:10") | Range("5:15") == Range("1:15") - assert Range(":10") | Range("9:") == Range(":") + assert Ranges("1:10") | Ranges("5:15") == Ranges("1:15") + assert Ranges(":10") | Ranges("9:") == Ranges(":") def test_or_operator_empty_range(): - expected = Range("1:10") - empty = Range("0:0") + expected = Ranges("1:10") + empty = Ranges("0:0") actual_left = expected | empty actual_right = empty | expected @@ -21,16 +21,16 @@ def test_or_operator_empty_range(): def test_or_operator_non_overlapping(): - combined = Range("1:10") | Range("11:15") + combined = Ranges("1:10") | Ranges("11:15") assert combined == "1:10,11:15" def test_union_adjacent_ranges(): - union = Range("0:5") | Range("5:10") + union = Ranges("0:5") | Ranges("5:10") assert union == "0:10" def test_iterator(): - assert list(Range("1:10")) == [1, 2, 3, 4, 5, 6, 7, 8, 9] - assert list(Range(":5")) == [0, 1, 2, 3, 4] - assert list(Range("3")) == [3] + assert list(Ranges("1:10")) == [1, 2, 3, 4, 5, 6, 7, 8, 9] + assert list(Ranges(":5")) == [0, 1, 2, 3, 4] + assert list(Ranges("3")) == [3] diff --git a/arranges/tests/range/operators/test_union.py b/arranges/tests/range/operators/test_union.py index 06d8800..db1b4a1 100644 --- a/arranges/tests/range/operators/test_union.py +++ b/arranges/tests/range/operators/test_union.py @@ -1,23 +1,23 @@ -from arranges import Range +from arranges import Ranges def test_join(): - assert Range("1:10").union(Range("5:15")) == Range("1:15") - assert Range(":10").union(Range("9:")) == Range(":") + assert Ranges("1:10").union(Ranges("5:15")) == Ranges("1:15") + assert Ranges(":10").union(Ranges("9:")) == Ranges(":") def test_join_adjacent(): - assert Range("1:10").union(Range("10:15")) == Range("1:15") - assert Range("1:10").union(Range("0:1")) == Range("0:10") + assert Ranges("1:10").union(Ranges("10:15")) == Ranges("1:15") + assert Ranges("1:10").union(Ranges("0:1")) == Ranges("0:10") def test_intersects(): - assert Range("1:10").intersects(Range("5:15")) - assert Range(":10").intersects(Range("9:")) + assert Ranges("1:10").intersects(Ranges("5:15")) + assert Ranges(":10").intersects(Ranges("9:")) - assert Range("5:10").intersects(Range(":")) - assert Range(":").intersects(Range(":")) - assert Range("1:100").intersects(Range("0:1000")) + assert Ranges("5:10").intersects(Ranges(":")) + assert Ranges(":").intersects(Ranges(":")) + assert Ranges("1:100").intersects(Ranges("0:1000")) - assert not Range("1:10").intersects(Range("11:15")) - assert not Range("1:10").intersects(Range("11:")) + assert not Ranges("1:10").intersects(Ranges("11:15")) + assert not Ranges("1:10").intersects(Ranges("11:")) diff --git a/arranges/tests/range/properties/test_last.py b/arranges/tests/range/properties/test_last.py index 2bfd167..e429740 100644 --- a/arranges/tests/range/properties/test_last.py +++ b/arranges/tests/range/properties/test_last.py @@ -1,15 +1,15 @@ -from arranges import Range +from arranges import Ranges from arranges.utils import inf def test_last_inf(): - assert Range(":").last == inf + assert Ranges(":").last == inf def test_last_num(): - assert Range(":10").last == 9 - assert Range("10").last == 10 + assert Ranges(":10").last == 9 + assert Ranges("10").last == 10 def test_last_empty(): - assert Range("").last == -1 + assert Ranges("").last == -1 diff --git a/arranges/tests/range/serialize/test_serialize.py b/arranges/tests/range/serialize/test_serialize.py index 6034e09..c09635f 100644 --- a/arranges/tests/range/serialize/test_serialize.py +++ b/arranges/tests/range/serialize/test_serialize.py @@ -1,2 +1,19 @@ +from pydantic import BaseModel + +from arranges import Ranges + + +class Model(BaseModel): + input_range: Ranges + + def test_serialize(): - raise NotImplementedError() + model = Model(input_range="0:10,20:30,15:") + + assert model.json() == '{"input_range": ":10,15:"}' + + +def test_deserialize(): + model = Model.parse_raw('{"input_range": "0:10,20:30,15:"}') + + assert model.input_range == ":10,15:" diff --git a/arranges/tests/range/serialize/test_validator.py b/arranges/tests/range/serialize/test_validator.py index d78bc61..c47008f 100644 --- a/arranges/tests/range/serialize/test_validator.py +++ b/arranges/tests/range/serialize/test_validator.py @@ -1,11 +1,11 @@ import pytest from pydantic import BaseModel, ValidationError -from arranges import Range +from arranges import Ranges class ModelWithRange(BaseModel): - range: Range + range: Ranges class Config: arbitrary_types_allowed = True @@ -19,7 +19,7 @@ def test_working_range_str(): def test_working_range(): - model = ModelWithRange(range=Range("1:10")) + model = ModelWithRange(range=Ranges("1:10")) assert model.range.first == 1 assert model.range.last == 9 From a718778fa7dee20e8079bbfa5ef1acbd53d51685 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 22:45:33 +0100 Subject: [PATCH 06/11] sftu flake8 --- .flake8 | 3 ++ .pre-commit-config.yaml | 2 +- arranges/src/arranges/ranges.py | 40 +++++++++++++------ arranges/tests/range/cache/__init__.py | 0 arranges/tests/range/cache/conftest.py | 8 ++++ arranges/tests/range/cache/test_cache.py | 11 +++++ .../range/operators/test_intersection.py | 14 +++++++ 7 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 .flake8 create mode 100644 arranges/tests/range/cache/__init__.py create mode 100644 arranges/tests/range/cache/conftest.py create mode 100644 arranges/tests/range/cache/test_cache.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..eddb95a --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] + +ignore = W503,E501 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f11b519..afdf388 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: rev: 6.0.0 hooks: - id: flake8 - args: [--ignore, E501] + args: ['--config=.flake8'] - repo: https://github.com/shellcheck-py/shellcheck-py rev: v0.9.0.2 hooks: diff --git a/arranges/src/arranges/ranges.py b/arranges/src/arranges/ranges.py index 42e7bcb..ee7bcd1 100644 --- a/arranges/src/arranges/ranges.py +++ b/arranges/src/arranges/ranges.py @@ -18,6 +18,7 @@ def __init__(self, value: Any, stop: range_idx | None = None): Construct a new string with the canonical form of the range. """ self.segments = self.from_str(self) + print("init:", value, type(value), self.segments) def __new__(cls, value: Any, stop: range_idx | None = None) -> str: """ @@ -25,7 +26,9 @@ def __new__(cls, value: Any, stop: range_idx | None = None) -> str: This becomes "self" in __init__, so we're always a string """ - return str.__new__(cls, cls.construct_str(value, stop)) + val = cls.construct_str(value, stop) + # print("new one!", value, stop, val) + return str.__new__(cls, val) @classmethod def construct_str(cls, value, stop) -> str: @@ -36,15 +39,13 @@ def construct_str(cls, value, stop) -> str: stop_only = value is None and stop is not None if start_and_stop or stop_only: - seg = Segment(value, stop) - print("ugh", value, stop, "equals", seg) - - return seg + return Segment(value, stop) if value is None: return "" if is_intlike(value): + print("value is intlike", value) return Segment(0, value) if is_rangelike(value): @@ -100,7 +101,7 @@ def iterable_to_str(cls, iterable: Iterable) -> str: @classmethod @lru_cache - def from_hashable_iterable(cls, value: tuple[Segment]) -> tuple[Segment]: + def from_hashable_iterable(cls, value: tuple[Any]) -> tuple[Segment]: """ Cache the result of from_iterable """ @@ -152,7 +153,7 @@ def __hash__(self): return super().__hash__() def __add__(self, other): - s = self.iterable_to_str((self, [other])) + s = self.iterable_to_str((self, other)) return Ranges(s) def __eq__(self, other: Any) -> bool: @@ -212,12 +213,25 @@ def __and__(self, other: "Ranges") -> "Ranges": """ Return the intersection of this range and the other """ - segments = [] - for r in self.segments: - for o in other.segments: - if r.intersects(o): - segments.append(r.intersection(o)) - return Ranges(segments) + # Create a sorted list of all the boundary points from both ranges. + boundary_points = sorted( + set( + [s.start for s in self.segments] + + [s.stop for s in self.segments] + + [s.start for s in other.segments] + + [s.stop for s in other.segments] + ) + ) + + # Use these boundary points to find intersecting segments. + intersected_segments = [] + for i in range(len(boundary_points) - 1): + start, end = boundary_points[i], boundary_points[i + 1] + new_seg = Segment(start, end) + if new_seg in self and new_seg in other: + intersected_segments.append(new_seg) + + return Ranges(intersected_segments) def __invert__(self): """ diff --git a/arranges/tests/range/cache/__init__.py b/arranges/tests/range/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arranges/tests/range/cache/conftest.py b/arranges/tests/range/cache/conftest.py new file mode 100644 index 0000000..4283e09 --- /dev/null +++ b/arranges/tests/range/cache/conftest.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.fixture(scope="function", autouse=True) +def test_id(request): + test_id = request.node.name + unique_int = hash(test_id) % 1_000_000 + return unique_int diff --git a/arranges/tests/range/cache/test_cache.py b/arranges/tests/range/cache/test_cache.py new file mode 100644 index 0000000..9417620 --- /dev/null +++ b/arranges/tests/range/cache/test_cache.py @@ -0,0 +1,11 @@ +from arranges import Ranges + + +def test_cache_int_vs_list(test_id): + from_zero = Ranges(test_id) + list_value = Ranges([test_id]) + + assert test_id in list_value + + assert from_zero == range(test_id) + assert list_value == [test_id] diff --git a/arranges/tests/range/operators/test_intersection.py b/arranges/tests/range/operators/test_intersection.py index 33e723b..f92f42c 100644 --- a/arranges/tests/range/operators/test_intersection.py +++ b/arranges/tests/range/operators/test_intersection.py @@ -70,3 +70,17 @@ def test_first_empty(): b = " *** " c = "" assert r(a) & r(b) == r(c) + + +def test_multi_split(): + a = " ** ** ** *** *****" + b = "** *** ** **" + c = " * ** ** **" + assert r(a) & r(b) == r(c) + + +def test_multi_split_single(): + a = " ** ** ** *** *****" + b = "* *" + c = " *" + assert r(a) & r(b) == r(c) From 3d324a06f2a57a751fbbeebb245202c8753cfec7 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 23:07:47 +0100 Subject: [PATCH 07/11] fix cache dogshit --- arranges/src/arranges/ranges.py | 7 +++++-- arranges/tests/range/cache/__init__.py | 0 arranges/tests/range/cache/test_cache.py | 11 ----------- 3 files changed, 5 insertions(+), 13 deletions(-) delete mode 100644 arranges/tests/range/cache/__init__.py delete mode 100644 arranges/tests/range/cache/test_cache.py diff --git a/arranges/src/arranges/ranges.py b/arranges/src/arranges/ranges.py index ee7bcd1..3fcfc82 100644 --- a/arranges/src/arranges/ranges.py +++ b/arranges/src/arranges/ranges.py @@ -45,7 +45,6 @@ def construct_str(cls, value, stop) -> str: return "" if is_intlike(value): - print("value is intlike", value) return Segment(0, value) if is_rangelike(value): @@ -161,7 +160,11 @@ def __eq__(self, other: Any) -> bool: Compare the two lists based on their string representations """ if not isinstance(other, Ranges): - other = Ranges(other) + # hack: bypass external constructor, use nested iterable + # otherwise we risk doing Ranges(int). + # todo: break _flatten out and separate internal and external + # constructors, + other = Ranges((other,)) return super().__eq__(other) def __contains__(self, other: Any) -> bool: diff --git a/arranges/tests/range/cache/__init__.py b/arranges/tests/range/cache/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/arranges/tests/range/cache/test_cache.py b/arranges/tests/range/cache/test_cache.py deleted file mode 100644 index 9417620..0000000 --- a/arranges/tests/range/cache/test_cache.py +++ /dev/null @@ -1,11 +0,0 @@ -from arranges import Ranges - - -def test_cache_int_vs_list(test_id): - from_zero = Ranges(test_id) - list_value = Ranges([test_id]) - - assert test_id in list_value - - assert from_zero == range(test_id) - assert list_value == [test_id] From b42a1b084f5ae056b7e9deaeaea22e197e8e3007 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 23:12:30 +0100 Subject: [PATCH 08/11] remove vestegial code "I had my appendixes out" Really? Appendix A or Appendix B? --- arranges/src/arranges/_range.py | 107 -------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 arranges/src/arranges/_range.py diff --git a/arranges/src/arranges/_range.py b/arranges/src/arranges/_range.py deleted file mode 100644 index 03c2249..0000000 --- a/arranges/src/arranges/_range.py +++ /dev/null @@ -1,107 +0,0 @@ -# from typing import Any - -# from arranges.segment import Segment -# from arranges.utils import as_type, inf, is_intlike, is_iterable, is_rangelike, to_int - - -# class Range: -# """ -# A range of numbers, similar to Python slice notation, but with no step or -# negative/relative ranges. -# """ - -# start: int = 0 -# stop: int = inf - -# def isdisjoint(self, other: Any) -> bool: -# """ -# Return True if this range is disjoint from the other range -# """ -# other = as_type(Range, other) -# return not self.intersects(other) - -# def issubset(self, other: Any) -> bool: -# """ -# Return True if this range is a subset of the other range -# """ -# other = as_type(Range, other) -# return self in other - -# def __le__(self, other: Any) -> bool: -# """ -# Return True if this range is a subset of the other range -# """ -# other = as_type(Range, other) -# return self in other - -# def __lt__(self, other: Any) -> bool: -# """ -# Return True if this range is a proper subset of the other range -# """ -# other = as_type(Range, other) -# return self in other and self != other - -# def issuperset(self, other: Any) -> bool: -# """ -# Return True if this range is a superset of the other range -# """ -# other = as_type(Range, other) -# return other in self - -# def __ge__(self, other: Any) -> bool: -# """ -# Return True if this range is a superset of the other range -# """ -# other = as_type(Range, other) -# return other in self - -# def __gt__(self, other: Any) -> bool: -# """ -# Return True if this range is a proper superset of the other range -# """ -# other = as_type(Range, other) -# return other in self and self != other - -# def __invert__(self) -> "Range": -# """ -# Return the inverse of this range (compliment) -# """ -# if not self: -# return Range(0, inf) - -# if self.start == 0: -# return Range(self.stop, inf) - -# if self.stop == inf: -# return Range(0, self.start) - -# raise ValueError("Inverting this range will cause a discontinuity") - -# def __and__(self, other: "Range") -> "Range": -# """ -# Return the intersection of this range and the other range -# """ -# return self.intersection(other) - -# def __sub__(self, other: "Range") -> "Range": -# """ -# Return the difference between this range and the other -# """ -# if not self.intersects(other): -# return Range(self) - -# # def difference(self, *others): -# # """ -# # Remove the other ranges from this one -# # """ -# # - -# # def symmetric_difference(self, other: "Range") -> "Range": -# # """ -# # Return the symmetric difference of two ranges -# # """ - -# # def __xor__(self, other: "Range") -> "Range": -# # """ -# # Return the symmetric difference of two ranges -# # """ From b13da81c4745fec17695f74c1c5dbc6a6ad4a5e1 Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 23:35:16 +0100 Subject: [PATCH 09/11] update pydantic ; --- arranges/src/arranges/ranges.py | 9 +++++++-- arranges/tests/range/serialize/test_serialize.py | 4 ++-- arranges/tests/range/serialize/test_validator.py | 6 ++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/arranges/src/arranges/ranges.py b/arranges/src/arranges/ranges.py index 3fcfc82..2b227c3 100644 --- a/arranges/src/arranges/ranges.py +++ b/arranges/src/arranges/ranges.py @@ -2,6 +2,9 @@ from functools import lru_cache from typing import Any, Iterable +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema, core_schema + from arranges.segment import Segment, range_idx from arranges.utils import inf, is_intlike, is_iterable, is_rangelike, try_hash @@ -269,11 +272,13 @@ def validate(cls, value: Any) -> "Ranges": return cls(value) @classmethod - def __get_validators__(cls): + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: """ For automatic validation in pydantic """ - yield cls.validate + return core_schema.no_info_after_validator_function(cls, handler(Any)) @property def first(self): diff --git a/arranges/tests/range/serialize/test_serialize.py b/arranges/tests/range/serialize/test_serialize.py index c09635f..54219ff 100644 --- a/arranges/tests/range/serialize/test_serialize.py +++ b/arranges/tests/range/serialize/test_serialize.py @@ -10,10 +10,10 @@ class Model(BaseModel): def test_serialize(): model = Model(input_range="0:10,20:30,15:") - assert model.json() == '{"input_range": ":10,15:"}' + assert model.model_dump_json() == '{"input_range":":10,15:"}' def test_deserialize(): - model = Model.parse_raw('{"input_range": "0:10,20:30,15:"}') + model = Model.model_validate_json('{"input_range":"0:10,20:30,15:"}') assert model.input_range == ":10,15:" diff --git a/arranges/tests/range/serialize/test_validator.py b/arranges/tests/range/serialize/test_validator.py index c47008f..b735726 100644 --- a/arranges/tests/range/serialize/test_validator.py +++ b/arranges/tests/range/serialize/test_validator.py @@ -1,14 +1,12 @@ import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError from arranges import Ranges class ModelWithRange(BaseModel): range: Ranges - - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) def test_working_range_str(): From 96db422667fac01138f26c4e3467d603eb191b2c Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 23:39:09 +0100 Subject: [PATCH 10/11] ... --- arranges/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arranges/pyproject.toml b/arranges/pyproject.toml index 01a8f7b..488e6ca 100644 --- a/arranges/pyproject.toml +++ b/arranges/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pydantic", + "pydantic==2.3.0", "flake8", "pre-commit", "pytest", From c802da0a26c7d9475311674cce7d004c4c92100e Mon Sep 17 00:00:00 2001 From: Gareth Davidson Date: Sun, 24 Sep 2023 23:41:03 +0100 Subject: [PATCH 11/11] bump pyver --- .github/workflows/unit-tests.yml | 2 +- arranges/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 92320dc..3c69cec 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -10,7 +10,7 @@ jobs: # You can use PyPy versions in python-version. # For example, pypy2.7 and pypy3.9 matrix: - python-version: ["3.9", "3.10", "3.11", "pypy3.9"] + python-version: ["3.10", "3.11"] steps: - uses: actions/checkout@v3 diff --git a/arranges/pyproject.toml b/arranges/pyproject.toml index 488e6ca..36ea31b 100644 --- a/arranges/pyproject.toml +++ b/arranges/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "License :: Public Domain", # WTFPL