From fb9cd112c772b53eab1a72cda6c7097b9e134deb Mon Sep 17 00:00:00 2001 From: CoprDistGit Date: Tue, 11 Apr 2023 17:57:41 +0000 Subject: automatic import of python-portion --- python-portion.spec | 3097 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3097 insertions(+) create mode 100644 python-portion.spec (limited to 'python-portion.spec') diff --git a/python-portion.spec b/python-portion.spec new file mode 100644 index 0000000..c2dd23c --- /dev/null +++ b/python-portion.spec @@ -0,0 +1,3097 @@ +%global _empty_manifest_terminate_build 0 +Name: python-portion +Version: 2.4.0 +Release: 1 +Summary: Python data structure and operations for intervals +License: LGPLv3 +URL: https://github.com/AlexandreDecan/portion +Source0: https://mirrors.nju.edu.cn/pypi/web/packages/c7/fb/a7472bbd59b208a34fdc53ed258550354dfa1092f1d672f30aee49031ff8/portion-2.4.0.tar.gz +BuildArch: noarch + +Requires: python3-sortedcontainers +Requires: python3-pytest +Requires: python3-coverage +Requires: python3-black + +%description +# portion - data structure and operations for intervals + +[![Tests](https://github.com/AlexandreDecan/portion/actions/workflows/test.yaml/badge.svg?branch=master)](https://github.com/AlexandreDecan/portion/actions/workflows/test.yaml) +[![Coverage Status](https://coveralls.io/repos/github/AlexandreDecan/portion/badge.svg?branch=master)](https://coveralls.io/github/AlexandreDecan/portion?branch=master) +[![License](https://badgen.net/pypi/license/portion)](https://github.com/AlexandreDecan/portion/blob/master/LICENSE.txt) +[![PyPI](https://badgen.net/pypi/v/portion)](https://pypi.org/project/portion) +[![Commits](https://badgen.net/github/last-commit/AlexandreDecan/portion)](https://github.com/AlexandreDecan/portion/commits/) + + +The `portion` library provides data structure and operations for intervals in Python 3.7+. + + - Support intervals of any (comparable) objects. + - Closed or open, finite or (semi-)infinite intervals. + - Interval sets (union of atomic intervals) are supported. + - Automatic simplification of intervals. + - Support comparison, transformation, intersection, union, complement, difference and containment. + - Provide test for emptiness, atomicity, overlap and adjacency. + - Discrete iterations on the values of an interval. + - Dict-like structure to map intervals to data. + - Import and export intervals to strings and to Python built-in data types. + - Heavily tested with high code coverage. + + +## Table of contents + + * [Installation](#installation) + * [Documentation & usage](#documentation--usage) + * [Interval creation](#interval-creation) + * [Interval bounds & attributes](#interval-bounds--attributes) + * [Interval operations](#interval-operations) + * [Comparison operators](#comparison-operators) + * [Interval transformation](#interval-transformation) + * [Discrete iteration](#discrete-iteration) + * [Map intervals to data](#map-intervals-to-data) + * [Import & export intervals to strings](#import--export-intervals-to-strings) + * [Import & export intervals to Python built-in data types](#import--export-intervals-to-python-built-in-data-types) + * [Specialize & customize intervals](#specialize--customize-intervals) + * [Changelog](#changelog) + * [Contributions](#contributions) + * [License](#license) + + +## Installation + +You can use `pip` to install it, as usual: `pip install portion`. This will install the latest available version from [PyPI](https://pypi.org/project/portion). +Pre-releases are available from the *master* branch on [GitHub](https://github.com/AlexandreDecan/portion) +and can be installed with `pip install git+https://github.com/AlexandreDecan/portion`. +Note that `portion` is also available on [conda-forge](https://anaconda.org/conda-forge/portion). + +You can install `portion` and its development environment using `pip install -e .[test]` at the root of this repository. This automatically installs [pytest](https://docs.pytest.org/en/latest/) (for the test suites) and [black](https://black.readthedocs.io/en/stable/) (for code formatting). + + +## Documentation & usage + +### Interval creation + +Assuming this library is imported using `import portion as P`, intervals can be easily +created using one of the following helpers: + +```python +>>> P.open(1, 2) +(1,2) +>>> P.closed(1, 2) +[1,2] +>>> P.openclosed(1, 2) +(1,2] +>>> P.closedopen(1, 2) +[1,2) +>>> P.singleton(1) +[1] +>>> P.empty() +() + +``` + +The bounds of an interval can be any arbitrary values, as long as they are comparable: + +```python +>>> P.closed(1.2, 2.4) +[1.2,2.4] +>>> P.closed('a', 'z') +['a','z'] +>>> import datetime +>>> P.closed(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10)) +[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)] + +``` + + +Infinite and semi-infinite intervals are supported using `P.inf` and `-P.inf` as upper or lower bounds. +These two objects support comparison with any other object. +When infinities are used as a lower or upper bound, the corresponding boundary is automatically converted to an open one. + +```python +>>> P.inf > 'a', P.inf > 0, P.inf > True +(True, True, True) +>>> P.openclosed(-P.inf, 0) +(-inf,0] +>>> P.closed(-P.inf, P.inf) # Automatically converted to an open interval +(-inf,+inf) + +``` + +Intervals created with this library are `Interval` instances. +An `Interval` instance is a disjunction of atomic intervals each representing a single interval (e.g. `[1,2]`). +Intervals can be iterated to access the underlying atomic intervals, sorted by their lower and upper bounds. + +```python +>>> list(P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21)) +[[0,1], (10,11), [20,21]] +>>> list(P.empty()) +[] + +``` + +Nested (sorted) intervals can also be retrieved with a position or a slice: + +```python +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[0] +[0,1] +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[-2] +(10,11) +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[:2] +[0,1] | (10,11) + +``` + +For convenience, intervals are automatically simplified: + +```python +>>> P.closed(0, 2) | P.closed(2, 4) +[0,4] +>>> P.closed(1, 2) | P.closed(3, 4) | P.closed(2, 3) +[1,4] +>>> P.empty() | P.closed(0, 1) +[0,1] +>>> P.closed(1, 2) | P.closed(2, 3) | P.closed(4, 5) +[1,3] | [4,5] + +``` + +Note that, by default, simplification of discrete intervals is **not** supported by `portion` (but it can be simulated though, see [#24](https://github.com/AlexandreDecan/portion/issues/24#issuecomment-604456362)). +For example, combining `[0,1]` with `[2,3]` will **not** result in `[0,3]` even if there is +no integer between `1` and `2`. +Refer to [Specialize & customize intervals](#specialize--customize-intervals) to see how to create and use specialized discrete intervals. + + + +[↑ back to top](#table-of-contents) +### Interval bounds & attributes + + +An `Interval` defines the following properties: + + - `i.empty` is `True` if and only if the interval is empty. + ```python + >>> P.closed(0, 1).empty + False + >>> P.closed(0, 0).empty + False + >>> P.openclosed(0, 0).empty + True + >>> P.empty().empty + True + + ``` + + - `i.atomic` is `True` if and only if the interval is empty or is a disjunction of a single interval. + ```python + >>> P.empty().atomic + True + >>> P.closed(0, 2).atomic + True + >>> (P.closed(0, 1) | P.closed(1, 2)).atomic + True + >>> (P.closed(0, 1) | P.closed(2, 3)).atomic + False + + ``` + + - `i.enclosure` refers to the smallest atomic interval that includes the current one. + ```python + >>> (P.closed(0, 1) | P.open(2, 3)).enclosure + [0,3) + + ``` + +The left and right boundaries, and the lower and upper bounds of an interval can be respectively accessed +with its `left`, `right`, `lower` and `upper` attributes. +The `left` and `right` bounds are either `P.CLOSED` or `P.OPEN`. +By definition, `P.CLOSED == ~P.OPEN` and vice-versa. + +```python +>> P.CLOSED, P.OPEN +CLOSED, OPEN +>>> x = P.closedopen(0, 1) +>>> x.left, x.lower, x.upper, x.right +(CLOSED, 0, 1, OPEN) + +``` + +By convention, empty intervals resolve to `(P.inf, -P.inf)`: + +```python +>>> i = P.empty() +>>> i.left, i.lower, i.upper, i.right +(OPEN, +inf, -inf, OPEN) + +``` + + +If the interval is not atomic, then `left` and `lower` refer to the lower bound of its enclosure, +while `right` and `upper` refer to the upper bound of its enclosure: + +```python +>>> x = P.open(0, 1) | P.closed(3, 4) +>>> x.left, x.lower, x.upper, x.right +(OPEN, 0, 4, CLOSED) + +``` + +One can easily check for some interval properties based on the bounds of an interval: + +```python +>>> x = P.openclosed(-P.inf, 0) +>>> # Check that interval is left/right closed +>>> x.left == P.CLOSED, x.right == P.CLOSED +(False, True) +>>> # Check that interval is left/right bounded +>>> x.lower == -P.inf, x.upper == P.inf +(True, False) +>>> # Check for singleton +>>> x.lower == x.upper +False + +``` + + + +[↑ back to top](#table-of-contents) +### Interval operations + +`Interval` instances support the following operations: + + - `i.intersection(other)` and `i & other` return the intersection of two intervals. + ```python + >>> P.closed(0, 2) & P.closed(1, 3) + [1,2] + >>> P.closed(0, 4) & P.open(2, 3) + (2,3) + >>> P.closed(0, 2) & P.closed(2, 3) + [2] + >>> P.closed(0, 2) & P.closed(3, 4) + () + + ``` + + - `i.union(other)` and `i | other` return the union of two intervals. + ```python + >>> P.closed(0, 1) | P.closed(1, 2) + [0,2] + >>> P.closed(0, 1) | P.closed(2, 3) + [0,1] | [2,3] + + ``` + + - `i.complement(other)` and `~i` return the complement of the interval. + ```python + >>> ~P.closed(0, 1) + (-inf,0) | (1,+inf) + >>> ~(P.open(-P.inf, 0) | P.open(1, P.inf)) + [0,1] + >>> ~P.open(-P.inf, P.inf) + () + + ``` + + - `i.difference(other)` and `i - other` return the difference between `i` and `other`. + ```python + >>> P.closed(0,2) - P.closed(1,2) + [0,1) + >>> P.closed(0, 4) - P.closed(1, 2) + [0,1) | (2,4] + + ``` + + - `i.contains(other)` and `other in i` hold if given item is contained in the interval. + It supports intervals and arbitrary comparable values. + ```python + >>> 2 in P.closed(0, 2) + True + >>> 2 in P.open(0, 2) + False + >>> P.open(0, 1) in P.closed(0, 2) + True + + ``` + + - `i.adjacent(other)` tests if the two intervals are adjacent, i.e., if they do not overlap and their union form a single atomic interval. + While this definition corresponds to the usual notion of adjacency for atomic + intervals, it has stronger requirements for non-atomic ones since it requires + all underlying atomic intervals to be adjacent (i.e. that one + interval fills the gaps between the atomic intervals of the other one). + ```python + >>> P.closed(0, 1).adjacent(P.openclosed(1, 2)) + True + >>> P.closed(0, 1).adjacent(P.closed(1, 2)) + False + >>> (P.closed(0, 1) | P.closed(2, 3)).adjacent(P.open(1, 2) | P.open(3, 4)) + True + >>> (P.closed(0, 1) | P.closed(2, 3)).adjacent(P.open(3, 4)) + False + >>> P.closed(0, 1).adjacent(P.open(1, 2) | P.open(3, 4)) + False + + ``` + + - `i.overlaps(other)` tests if there is an overlap between two intervals. + ```python + >>> P.closed(1, 2).overlaps(P.closed(2, 3)) + True + >>> P.closed(1, 2).overlaps(P.open(2, 3)) + False + + ``` + +Finally, intervals are hashable as long as their bounds are hashable (and we have defined a hash value for `P.inf` and `-P.inf`). + + +[↑ back to top](#table-of-contents) +### Comparison operators + +Equality between intervals can be checked with the classical `==` operator: + +```python +>>> P.closed(0, 2) == P.closed(0, 1) | P.closed(1, 2) +True +>>> P.closed(0, 2) == P.open(0, 2) +False + +``` + +Moreover, intervals are comparable using `>`, `>=`, `<` or `<=`. +These comparison operators have a different behaviour than the usual ones. +For instance, `a < b` holds if all values in `a` are lower than the minimal value of `b` (i.e., `a` is +entirely on the left of the lower bound of `b`). + +```python +>>> P.closed(0, 1) < P.closed(2, 3) +True +>>> P.closed(0, 1) < P.closed(1, 2) +False + +``` + +Similarly, `a <= b` if all values in `a` are lower than the maximal value of `b` (i.e., `a` is +entirely on the left of the upper bound of `b`). + +```python +>>> P.closed(0, 1) <= P.closed(2, 3) +True +>>> P.closed(0, 2) <= P.closed(1, 3) +True +>>> P.closed(0, 3) <= P.closed(1, 2) +False + +``` + +If an interval needs to be compared against a single value, convert the value to a singleton interval first: + +```python +>>> P.singleton(0) < P.closed(0, 10) +False +>>> P.singleton(0) <= P.closed(0, 10) +True +>>> P.singleton(5) <= P.closed(0, 10) +True +>>> P.closed(0, 1) < P.singleton(2) +True + +``` + +Note that all these semantics differ from classical comparison operators. +As a consequence, the empty interval is never `<`, `<=`, `>` nor `>=` than any other interval, and +no interval is `<`, `>`, `<=` or `>=` when compared to the empty interval. + +```python +>>> e = P.empty() +>>> e < e or e > e or e <= e or e >= e +False +>>> i = P.closed(0, 1) +>>> e < i or e <= i or e > i or e >= i +False + +``` + +Moreover, some non-empty intervals are also not comparable in the classical sense, as illustrated hereafter: + +```python +>>> a, b = P.closed(0, 4), P.closed(1, 2) +>>> a < b or a > b +False +>>> a <= b or a >= b +False +>>> b <= a and b >= a +True + +``` + +As a general rule, if `a < b` holds, then `a <= b`, `b > a`, `b >= a`, +`not (a > b)`, `not (b < a)`, `not (a >= b)`, and `not (b <= a)` hold. + + + +[↑ back to top](#table-of-contents) +### Interval transformation + +Intervals are immutable but provide a `replace` method to create a new interval based on the +current one. This method accepts four optional parameters `left`, `lower`, `upper`, and `right`: + +```python +>>> i = P.closed(0, 2) +>>> i.replace(P.OPEN, -1, 3, P.CLOSED) +(-1,3] +>>> i.replace(lower=1, right=P.OPEN) +[1,2) + +``` + +Functions can be passed instead of values. If a function is passed, it is called with the current corresponding +value. + +```python +>>> P.closed(0, 2).replace(upper=lambda x: 2 * x) +[0,4] + +``` + +The provided function won't be called on infinities, unless `ignore_inf` is set to `False`. + +```python +>>> i = P.closedopen(0, P.inf) +>>> i.replace(upper=lambda x: 10) # No change, infinity is ignored +[0,+inf) +>>> i.replace(upper=lambda x: 10, ignore_inf=False) # Infinity is not ignored +[0,10) + +``` + +When `replace` is applied on an interval that is not atomic, it is extended and/or restricted such that +its enclosure satisfies the new bounds. + +```python +>>> i = P.openclosed(0, 1) | P.closed(5, 10) +>>> i.replace(P.CLOSED, -1, 8, P.OPEN) +[-1,1] | [5,8) +>>> i.replace(lower=4) +(4,10] + +``` + +To apply arbitrary transformations on the underlying atomic intervals, intervals expose an `apply` method that acts like `map`. +This method accepts a function that will be applied on each of the underlying atomic intervals to perform the desired transformation. +The provided function is expected to return either an `Interval`, or a 4-uple `(left, lower, upper, right)`. + +```python +>>> i = P.closed(2, 3) | P.open(4, 5) +>>> # Increment bound values +>>> i.apply(lambda x: (x.left, x.lower + 1, x.upper + 1, x.right)) +[3,4] | (5,6) +>>> # Invert bounds +>>> i.apply(lambda x: (~x.left, x.lower, x.upper, ~x.right)) +(2,3) | [4,5] + +``` + +The `apply` method is very powerful when used in combination with `replace`. +Because the latter allows functions to be passed as parameters and ignores infinities by default, it can be +conveniently used to transform (disjunction of) intervals in presence of infinities. + +```python +>>> i = P.openclosed(-P.inf, 0) | P.closed(3, 4) | P.closedopen(8, P.inf) +>>> # Increment bound values +>>> i.apply(lambda x: x.replace(upper=lambda v: v + 1)) +(-inf,1] | [3,5] | [8,+inf) +>>> # Intervals are still automatically simplified +>>> i.apply(lambda x: x.replace(lower=lambda v: v * 2)) +(-inf,0] | [16,+inf) +>>> # Invert bounds +>>> i.apply(lambda x: x.replace(left=lambda v: ~v, right=lambda v: ~v)) +(-inf,0) | (3,4) | (8,+inf) +>>> # Replace infinities with -10 and 10 +>>> conv = lambda v: -10 if v == -P.inf else (10 if v == P.inf else v) +>>> i.apply(lambda x: x.replace(lower=conv, upper=conv, ignore_inf=False)) +(-10,0] | [3,4] | [8,10) + +``` + + +[↑ back to top](#table-of-contents) +### Discrete iteration + +The `iterate` function takes an interval, and returns a generator to iterate over +the values of an interval. Obviously, as intervals are continuous, it is required to specify the + `step` between consecutive values. The iteration then starts from the lower bound and ends on the upper one. Only values contained by the interval are returned this way. + +```python +>>> list(P.iterate(P.closed(0, 3), step=1)) +[0, 1, 2, 3] +>>> list(P.iterate(P.closed(0, 3), step=2)) +[0, 2] +>>> list(P.iterate(P.open(0, 3), step=2)) +[2] + +``` + +When an interval is not atomic, `iterate` consecutively iterates on all underlying atomic +intervals, starting from each lower bound and ending on each upper one: + +```python +>>> list(P.iterate(P.singleton(0) | P.singleton(3) | P.singleton(5), step=2)) # Won't be [0] +[0, 3, 5] +>>> list(P.iterate(P.closed(0, 2) | P.closed(4, 6), step=3)) # Won't be [0, 6] +[0, 4] + +``` + +By default, the iteration always starts on the lower bound of each underlying atomic interval. +The `base` parameter can be used to change this behaviour, by specifying how the initial value to start +the iteration from must be computed. This parameter accepts a callable that is called with the lower +bound of each underlying atomic interval, and that returns the initial value to start the iteration from. +It can be helpful to deal with (semi-)infinite intervals, or to *align* the generated values of the iterator: + +```python +>>> # Align on integers +>>> list(P.iterate(P.closed(0.3, 4.9), step=1, base=int)) +[1, 2, 3, 4] +>>> # Restrict values of a (semi-)infinite interval +>>> list(P.iterate(P.openclosed(-P.inf, 2), step=1, base=lambda x: max(0, x))) +[0, 1, 2] + +``` + +The `base` parameter can be used to change how `iterate` applies on unions of atomic interval, by +specifying a function that returns a single value, as illustrated next: + +```python +>>> base = lambda x: 0 +>>> list(P.iterate(P.singleton(0) | P.singleton(3) | P.singleton(5), step=2, base=base)) +[0] +>>> list(P.iterate(P.closed(0, 2) | P.closed(4, 6), step=3, base=base)) +[0, 6] + +``` + +Notice that defining `base` such that it returns a single value can be extremely inefficient in +terms of performance when the intervals are "far apart" each other (i.e., when the *gaps* between +atomic intervals are large). + +Finally, iteration can be performed in reverse order by specifying `reverse=True`. + +```python +>>> list(P.iterate(P.closed(0, 3), step=-1, reverse=True)) # Mind step=-1 +[3, 2, 1, 0] +>>> list(P.iterate(P.closed(0, 3), step=-2, reverse=True)) # Mind step=-2 +[3, 1] + +``` + +Again, this library does not make any assumption about the objects being used in an interval, as long as they +are comparable. However, it is not always possible to provide a meaningful value for `step` (e.g., what would +be the step between two consecutive characters?). In these cases, a callable can be passed instead of a value. +This callable will be called with the current value, and is expected to return the next possible value. + +```python +>>> list(P.iterate(P.closed('a', 'd'), step=lambda d: chr(ord(d) + 1))) +['a', 'b', 'c', 'd'] +>>> # Since we reversed the order, we changed "+" to "-" in the lambda. +>>> list(P.iterate(P.closed('a', 'd'), step=lambda d: chr(ord(d) - 1), reverse=True)) +['d', 'c', 'b', 'a'] + +``` + + + +[↑ back to top](#table-of-contents) +### Map intervals to data + +The library provides an `IntervalDict` class, a `dict`-like data structure to store and query data +along with intervals. Any value can be stored in such data structure as long as it supports +equality. + + +```python +>>> d = P.IntervalDict() +>>> d[P.closed(0, 3)] = 'banana' +>>> d[4] = 'apple' +>>> d +{[0,3]: 'banana', [4]: 'apple'} + +``` + +When a value is defined for an interval that overlaps an existing one, it is automatically updated +to take the new value into account: + +```python +>>> d[P.closed(2, 4)] = 'orange' +>>> d +{[0,2): 'banana', [2,4]: 'orange'} + +``` + +An `IntervalDict` can be queried using single values or intervals. If a single value is used as a +key, its behaviour corresponds to the one of a classical `dict`: + +```python +>>> d[2] +'orange' +>>> d[5] # Key does not exist +Traceback (most recent call last): + ... +KeyError: 5 +>>> d.get(5, default=0) +0 + +``` + +When the key is an interval, a new `IntervalDict` containing the values +for the specified key is returned: + +```python +>>> d[~P.empty()] # Get all values, similar to d.copy() +{[0,2): 'banana', [2,4]: 'orange'} +>>> d[P.closed(1, 3)] +{[1,2): 'banana', [2,3]: 'orange'} +>>> d[P.closed(-2, 1)] +{[0,1]: 'banana'} +>>> d[P.closed(-2, -1)] +{} + +``` + +By using `.get`, a default value (defaulting to `None`) can be specified. +This value is used to "fill the gaps" if the queried interval is not completely +covered by the `IntervalDict`: + +```python +>>> d.get(P.closed(-2, 1), default='peach') +{[-2,0): 'peach', [0,1]: 'banana'} +>>> d.get(P.closed(-2, -1), default='peach') +{[-2,-1]: 'peach'} +>>> d.get(P.singleton(1), default='peach') # Key is covered, default is not used +{[1]: 'banana'} + +``` + +For convenience, an `IntervalDict` provides a way to look for specific data values. +The `.find` method always returns a (possibly empty) `Interval` instance for which given +value is defined: + +```python +>>> d.find('banana') +[0,2) +>>> d.find('orange') +[2,4] +>>> d.find('carrot') +() + +``` + +The active domain of an `IntervalDict` can be retrieved with its `.domain` method. +This method always returns a single `Interval` instance, where `.keys` returns a sorted view of disjoint intervals. + +```python +>>> d.domain() +[0,4] +>>> list(d.keys()) +[[0,2), [2,4]] +>>> list(d.values()) +['banana', 'orange'] +>>> list(d.items()) +[([0,2), 'banana'), ([2,4], 'orange')] + +``` + +The `.keys`, `.values` and `.items` methods return exactly one element for each stored value (i.e., if two intervals share a value, they are merged into a disjunction), as illustrated next. +See [#44](https://github.com/AlexandreDecan/portion/issues/44#issuecomment-710199687) to know how to obtained a sorted list of atomic intervals instead. + +```python +>>> d = P.IntervalDict() +>>> d[P.closed(0, 1)] = d[P.closed(2, 3)] = 'peach' +>>> list(d.items()) +[([0,1] | [2,3], 'peach')] + +``` + + +Two `IntervalDict` instances can be combined together using the `.combine` method. +This method returns a new `IntervalDict` whose keys and values are taken from the two +source `IntervalDict`. Values corresponding to non-intersecting keys are simply copied, +while values corresponding to intersecting keys are combined together using the provided +function, as illustrated hereafter: + +```python +>>> d1 = P.IntervalDict({P.closed(0, 2): 'banana'}) +>>> d2 = P.IntervalDict({P.closed(1, 3): 'orange'}) +>>> concat = lambda x, y: x + '/' + y +>>> d1.combine(d2, how=concat) +{[0,1): 'banana', [1,2]: 'banana/orange', (2,3]: 'orange'} + +``` + +Resulting keys always correspond to an outer join. Other joins can be easily simulated +by querying the resulting `IntervalDict` as follows: + +```python +>>> d = d1.combine(d2, how=concat) +>>> d[d1.domain()] # Left join +{[0,1): 'banana', [1,2]: 'banana/orange'} +>>> d[d2.domain()] # Right join +{[1,2]: 'banana/orange', (2,3]: 'orange'} +>>> d[d1.domain() & d2.domain()] # Inner join +{[1,2]: 'banana/orange'} + +``` + +Finally, similarly to a `dict`, an `IntervalDict` also supports `len`, `in` and `del`, and defines +`.clear`, `.copy`, `.update`, `.pop`, `.popitem`, and `.setdefault`. +For convenience, one can export the content of an `IntervalDict` to a classical Python `dict` using +the `as_dict` method. This method accepts an optional `atomic` parameter (whose default is `False`). When set to `True`, the keys of the resulting `dict` instance are atomic intervals. + + +[↑ back to top](#table-of-contents) +### Import & export intervals to strings + +Intervals can be exported to string, either using `repr` (as illustrated above) or with the `to_string` function. + +```python +>>> P.to_string(P.closedopen(0, 1)) +'[0,1)' + +``` + +The way string representations are built can be easily parametrized using the various parameters supported by +`to_string`: + +```python +>>> params = { +... 'disj': ' or ', +... 'sep': ' - ', +... 'left_closed': '<', +... 'right_closed': '>', +... 'left_open': '..', +... 'right_open': '..', +... 'pinf': '+oo', +... 'ninf': '-oo', +... 'conv': lambda v: '"{}"'.format(v), +... } +>>> x = P.openclosed(0, 1) | P.closed(2, P.inf) +>>> P.to_string(x, **params) +'.."0" - "1"> or <"2" - +oo..' + +``` + +Similarly, intervals can be created from a string using the `from_string` function. +A conversion function (`conv` parameter) has to be provided to convert a bound (as string) to a value. + +```python +>>> P.from_string('[0, 1]', conv=int) == P.closed(0, 1) +True +>>> P.from_string('[1.2]', conv=float) == P.singleton(1.2) +True +>>> converter = lambda s: datetime.datetime.strptime(s, '%Y/%m/%d') +>>> P.from_string('[2011/03/15, 2013/10/10]', conv=converter) +[datetime.datetime(2011, 3, 15, 0, 0),datetime.datetime(2013, 10, 10, 0, 0)] + +``` + +Similarly to `to_string`, function `from_string` can be parametrized to deal with more elaborated inputs. +Notice that as `from_string` expects regular expression patterns, we need to escape some characters. + +```python +>>> s = '.."0" - "1"> or <"2" - +oo..' +>>> params = { +... 'disj': ' or ', +... 'sep': ' - ', +... 'left_closed': '<', +... 'right_closed': '>', +... 'left_open': r'\.\.', # from_string expects regular expression patterns +... 'right_open': r'\.\.', # from_string expects regular expression patterns +... 'pinf': r'\+oo', # from_string expects regular expression patterns +... 'ninf': '-oo', +... 'conv': lambda v: int(v[1:-1]), +... } +>>> P.from_string(s, **params) +(0,1] | [2,+inf) + +``` + +When a bound contains a comma or has a representation that cannot be automatically parsed with `from_string`, +the `bound` parameter can be used to specify the regular expression that should be used to match its representation. + +```python +>>> s = '[(0, 1), (2, 3)]' # Bounds are expected to be tuples +>>> P.from_string(s, conv=eval, bound=r'\(.+?\)') +[(0, 1),(2, 3)] + +``` + + +[↑ back to top](#table-of-contents) +### Import & export intervals to Python built-in data types + +Intervals can also be exported to a list of 4-uples with `to_data`, e.g., to support JSON serialization. +`P.CLOSED` and `P.OPEN` are represented by Boolean values `True` (inclusive) and `False` (exclusive). + +```python +>>> P.to_data(P.openclosed(0, 2)) +[(False, 0, 2, True)] + +``` + +The values used to represent positive and negative infinities can be specified with +`pinf` and `ninf`. They default to `float('inf')` and `float('-inf')` respectively. + +```python +>>> x = P.openclosed(0, 1) | P.closedopen(2, P.inf) +>>> P.to_data(x) +[(False, 0, 1, True), (True, 2, inf, False)] + +``` + +The function to convert bounds can be specified with the `conv` parameter. + +```python +>>> x = P.closedopen(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10)) +>>> P.to_data(x, conv=lambda v: (v.year, v.month, v.day)) +[(True, (2011, 3, 15), (2013, 10, 10), False)] + +``` + +Intervals can be imported from such a list of 4-tuples with `from_data`. +The same set of parameters can be used to specify how bounds and infinities are converted. + +```python +>>> x = [(True, (2011, 3, 15), (2013, 10, 10), False)] +>>> P.from_data(x, conv=lambda v: datetime.date(*v)) +[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)) + +``` + + +[↑ back to top](#table-of-contents) +### Specialize & customize intervals + +**Disclaimer**: the features explained in this section are still experimental and are subject to backward incompatible changes even in minor or patch updates of `portion`. + +The intervals provided by `portion` already cover a wide range of use cases. +However, in some situations, it might be interesting to specialize or customize these intervals. +One typical example would be to support discrete intervals such as intervals of integers. + +While it is definitely possible to rely on the default intervals provided by `portion` to encode discrete +intervals, there are a few edge cases that lead some operations to return unexpected results: + +```python +>>> P.singleton(0) | P.singleton(1) # Case 1: should be [0,1] for discrete numbers +[0] | [1] +>>> P.open(0, 1) # Case 2: should be empty +(0,1) +>>> P.closedopen(0, 1) # Case 3: should be singleton [0] +[0,1) + +``` + +The `portion` library makes its best to ease defining and using subclasses of `Interval` to address +these situations. In particular, `Interval` instances always produce new intervals using `self.__class__`, and the class is written in a way that most of its methods can be easily extended. + +To implement a class for intervals of discrete numbers and to cover the three aforementioned cases, we need to change the behaviour of the `Interval._mergeable` class method (to address first case) and of the `Interval.from_atomic` class method (for cases 2 and 3). +The former is used to detect whether two atomic intervals can be merged into a single interval, while the latter is used to create atomic intervals. + +Thankfully, since discrete intervals are expected to be a frequent use case, `portion` provides an `AbstractDiscreteInterval` class that already makes the appropriate changes to these two methods. +As indicated by its name, this class cannot be used directly and should be inherited. +In particular, one has either to provide a `_step` class attribute to define the step between consecutive discrete values, or to define the `_incr` and `_decr` class methods: + +```python +>>> class IntInterval(P.AbstractDiscreteInterval): +... _step = 1 + +``` + +That's all! +We can now use this class to manipulate intervals of discrete numbers and see it covers the three problematic cases: + +```python +>>> IntInterval.from_atomic(P.CLOSED, 0, 0, P.CLOSED) | IntInterval.from_atomic(P.CLOSED, 1, 1, P.CLOSED) +[0,1] +>>> IntInterval.from_atomic(P.OPEN, 0, 1, P.OPEN) +() +>>> IntInterval.from_atomic(P.CLOSED, 0, 1, P.OPEN) +[0] + +``` + +As an example of using `_incr` and `_decr`, consider the following `CharInterval` subclass tailored to manipulate intervals of characters: + +```python +>>> class CharInterval(P.AbstractDiscreteInterval): +... _incr = lambda v: chr(ord(v) + 1) +... _decr = lambda v: chr(ord(v) - 1) +>>> CharInterval.from_atomic(P.OPEN, 'a', 'z', P.OPEN) +['b','y'] + +``` + +Having to call `from_atomic` on the subclass to create intervals is quite verbose. +For convenience, all the functions that create interval instances accept an additional `klass` parameter to specify the class that creates intervals, circumventing the direct use of the class constructors. +However, having to specify the `klass` parameter in each call to `P.closed` or other helpers that create intervals is still a bit too verbose to be convenient. +Consequently, `portion` provides a `create_api` function that, given a subclass of `Interval`, returns a dynamically generated module whose API is similar to the one of `portion` but configured to use the subclass instead: + +```python +>>> D = P.create_api(IntInterval) +>>> D.singleton(0) | D.singleton(1) +[0,1] +>>> D.open(0, 1) +() +>>> D.closedopen(0, 1) +[0] + +``` + +This makes it easy to use our newly defined `IntInterval` subclass while still benefiting from `portion`'s API. + +Let's extend our example to support intervals of natural numbers. +Such intervals are quite similar to the above ones, except they cannot go over negative values. +We can prevent the bounds of an interval to be negative by slightly changing the `from_atomic` class method as follows: + +```python +>>> class NaturalInterval(IntInterval): +... @classmethod +... def from_atomic(cls, left, lower, upper, right): +... return super().from_atomic( +... P.CLOSED if lower < 0 else left, +... max(0, lower), +... upper, +... right, +... ) + +``` + +We can now define and use the `N` module to check whether our newly defined `NaturalInterval` does the job: + +```python +>>> N = P.create_api(NaturalInterval) +>>> N.closed(-10, 2) +[0,2] +>>> N.open(-10, 2) +[0,1] +>>> ~N.empty() +[0,+inf) + +``` + +Keep in mind that just because `NaturalInterval` has semantics associated with natural numbers does not mean that all possible operations on these intervals strictly comply the semantics. +The following examples illustrate some of the cases where additional checks should be implemented to strictly adhere to these semantics: + +```python +>>> N.closed(1.5, 2.5) # Bounds are not natural numbers +[1.5,2.5] +>>> 0.5 in N.closed(0, 1) # Given value is not a natural number +True +>>> ~N.singleton(0.5) +[1.5,+inf) + +``` + + + +[↑ back to top](#table-of-contents) +## Changelog + +This library adheres to a [semantic versioning](https://semver.org) scheme. +See [CHANGELOG.md](https://github.com/AlexandreDecan/portion/blob/master/CHANGELOG.md) for the list of changes. + + + +## Contributions + +Contributions are very welcome! +Feel free to report bugs or suggest new features using GitHub issues and/or pull requests. + + + +## License + +Distributed under [LGPLv3 - GNU Lesser General Public License, version 3](https://github.com/AlexandreDecan/portion/blob/master/LICENSE.txt). + +You can refer to this library using: + +``` +@software{portion, + author = {Decan, Alexandre}, + title = {portion: Python data structure and operations for intervals}, + url = {https://github.com/AlexandreDecan/portion}, +} +``` + + + + +%package -n python3-portion +Summary: Python data structure and operations for intervals +Provides: python-portion +BuildRequires: python3-devel +BuildRequires: python3-setuptools +BuildRequires: python3-pip +%description -n python3-portion +# portion - data structure and operations for intervals + +[![Tests](https://github.com/AlexandreDecan/portion/actions/workflows/test.yaml/badge.svg?branch=master)](https://github.com/AlexandreDecan/portion/actions/workflows/test.yaml) +[![Coverage Status](https://coveralls.io/repos/github/AlexandreDecan/portion/badge.svg?branch=master)](https://coveralls.io/github/AlexandreDecan/portion?branch=master) +[![License](https://badgen.net/pypi/license/portion)](https://github.com/AlexandreDecan/portion/blob/master/LICENSE.txt) +[![PyPI](https://badgen.net/pypi/v/portion)](https://pypi.org/project/portion) +[![Commits](https://badgen.net/github/last-commit/AlexandreDecan/portion)](https://github.com/AlexandreDecan/portion/commits/) + + +The `portion` library provides data structure and operations for intervals in Python 3.7+. + + - Support intervals of any (comparable) objects. + - Closed or open, finite or (semi-)infinite intervals. + - Interval sets (union of atomic intervals) are supported. + - Automatic simplification of intervals. + - Support comparison, transformation, intersection, union, complement, difference and containment. + - Provide test for emptiness, atomicity, overlap and adjacency. + - Discrete iterations on the values of an interval. + - Dict-like structure to map intervals to data. + - Import and export intervals to strings and to Python built-in data types. + - Heavily tested with high code coverage. + + +## Table of contents + + * [Installation](#installation) + * [Documentation & usage](#documentation--usage) + * [Interval creation](#interval-creation) + * [Interval bounds & attributes](#interval-bounds--attributes) + * [Interval operations](#interval-operations) + * [Comparison operators](#comparison-operators) + * [Interval transformation](#interval-transformation) + * [Discrete iteration](#discrete-iteration) + * [Map intervals to data](#map-intervals-to-data) + * [Import & export intervals to strings](#import--export-intervals-to-strings) + * [Import & export intervals to Python built-in data types](#import--export-intervals-to-python-built-in-data-types) + * [Specialize & customize intervals](#specialize--customize-intervals) + * [Changelog](#changelog) + * [Contributions](#contributions) + * [License](#license) + + +## Installation + +You can use `pip` to install it, as usual: `pip install portion`. This will install the latest available version from [PyPI](https://pypi.org/project/portion). +Pre-releases are available from the *master* branch on [GitHub](https://github.com/AlexandreDecan/portion) +and can be installed with `pip install git+https://github.com/AlexandreDecan/portion`. +Note that `portion` is also available on [conda-forge](https://anaconda.org/conda-forge/portion). + +You can install `portion` and its development environment using `pip install -e .[test]` at the root of this repository. This automatically installs [pytest](https://docs.pytest.org/en/latest/) (for the test suites) and [black](https://black.readthedocs.io/en/stable/) (for code formatting). + + +## Documentation & usage + +### Interval creation + +Assuming this library is imported using `import portion as P`, intervals can be easily +created using one of the following helpers: + +```python +>>> P.open(1, 2) +(1,2) +>>> P.closed(1, 2) +[1,2] +>>> P.openclosed(1, 2) +(1,2] +>>> P.closedopen(1, 2) +[1,2) +>>> P.singleton(1) +[1] +>>> P.empty() +() + +``` + +The bounds of an interval can be any arbitrary values, as long as they are comparable: + +```python +>>> P.closed(1.2, 2.4) +[1.2,2.4] +>>> P.closed('a', 'z') +['a','z'] +>>> import datetime +>>> P.closed(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10)) +[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)] + +``` + + +Infinite and semi-infinite intervals are supported using `P.inf` and `-P.inf` as upper or lower bounds. +These two objects support comparison with any other object. +When infinities are used as a lower or upper bound, the corresponding boundary is automatically converted to an open one. + +```python +>>> P.inf > 'a', P.inf > 0, P.inf > True +(True, True, True) +>>> P.openclosed(-P.inf, 0) +(-inf,0] +>>> P.closed(-P.inf, P.inf) # Automatically converted to an open interval +(-inf,+inf) + +``` + +Intervals created with this library are `Interval` instances. +An `Interval` instance is a disjunction of atomic intervals each representing a single interval (e.g. `[1,2]`). +Intervals can be iterated to access the underlying atomic intervals, sorted by their lower and upper bounds. + +```python +>>> list(P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21)) +[[0,1], (10,11), [20,21]] +>>> list(P.empty()) +[] + +``` + +Nested (sorted) intervals can also be retrieved with a position or a slice: + +```python +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[0] +[0,1] +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[-2] +(10,11) +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[:2] +[0,1] | (10,11) + +``` + +For convenience, intervals are automatically simplified: + +```python +>>> P.closed(0, 2) | P.closed(2, 4) +[0,4] +>>> P.closed(1, 2) | P.closed(3, 4) | P.closed(2, 3) +[1,4] +>>> P.empty() | P.closed(0, 1) +[0,1] +>>> P.closed(1, 2) | P.closed(2, 3) | P.closed(4, 5) +[1,3] | [4,5] + +``` + +Note that, by default, simplification of discrete intervals is **not** supported by `portion` (but it can be simulated though, see [#24](https://github.com/AlexandreDecan/portion/issues/24#issuecomment-604456362)). +For example, combining `[0,1]` with `[2,3]` will **not** result in `[0,3]` even if there is +no integer between `1` and `2`. +Refer to [Specialize & customize intervals](#specialize--customize-intervals) to see how to create and use specialized discrete intervals. + + + +[↑ back to top](#table-of-contents) +### Interval bounds & attributes + + +An `Interval` defines the following properties: + + - `i.empty` is `True` if and only if the interval is empty. + ```python + >>> P.closed(0, 1).empty + False + >>> P.closed(0, 0).empty + False + >>> P.openclosed(0, 0).empty + True + >>> P.empty().empty + True + + ``` + + - `i.atomic` is `True` if and only if the interval is empty or is a disjunction of a single interval. + ```python + >>> P.empty().atomic + True + >>> P.closed(0, 2).atomic + True + >>> (P.closed(0, 1) | P.closed(1, 2)).atomic + True + >>> (P.closed(0, 1) | P.closed(2, 3)).atomic + False + + ``` + + - `i.enclosure` refers to the smallest atomic interval that includes the current one. + ```python + >>> (P.closed(0, 1) | P.open(2, 3)).enclosure + [0,3) + + ``` + +The left and right boundaries, and the lower and upper bounds of an interval can be respectively accessed +with its `left`, `right`, `lower` and `upper` attributes. +The `left` and `right` bounds are either `P.CLOSED` or `P.OPEN`. +By definition, `P.CLOSED == ~P.OPEN` and vice-versa. + +```python +>> P.CLOSED, P.OPEN +CLOSED, OPEN +>>> x = P.closedopen(0, 1) +>>> x.left, x.lower, x.upper, x.right +(CLOSED, 0, 1, OPEN) + +``` + +By convention, empty intervals resolve to `(P.inf, -P.inf)`: + +```python +>>> i = P.empty() +>>> i.left, i.lower, i.upper, i.right +(OPEN, +inf, -inf, OPEN) + +``` + + +If the interval is not atomic, then `left` and `lower` refer to the lower bound of its enclosure, +while `right` and `upper` refer to the upper bound of its enclosure: + +```python +>>> x = P.open(0, 1) | P.closed(3, 4) +>>> x.left, x.lower, x.upper, x.right +(OPEN, 0, 4, CLOSED) + +``` + +One can easily check for some interval properties based on the bounds of an interval: + +```python +>>> x = P.openclosed(-P.inf, 0) +>>> # Check that interval is left/right closed +>>> x.left == P.CLOSED, x.right == P.CLOSED +(False, True) +>>> # Check that interval is left/right bounded +>>> x.lower == -P.inf, x.upper == P.inf +(True, False) +>>> # Check for singleton +>>> x.lower == x.upper +False + +``` + + + +[↑ back to top](#table-of-contents) +### Interval operations + +`Interval` instances support the following operations: + + - `i.intersection(other)` and `i & other` return the intersection of two intervals. + ```python + >>> P.closed(0, 2) & P.closed(1, 3) + [1,2] + >>> P.closed(0, 4) & P.open(2, 3) + (2,3) + >>> P.closed(0, 2) & P.closed(2, 3) + [2] + >>> P.closed(0, 2) & P.closed(3, 4) + () + + ``` + + - `i.union(other)` and `i | other` return the union of two intervals. + ```python + >>> P.closed(0, 1) | P.closed(1, 2) + [0,2] + >>> P.closed(0, 1) | P.closed(2, 3) + [0,1] | [2,3] + + ``` + + - `i.complement(other)` and `~i` return the complement of the interval. + ```python + >>> ~P.closed(0, 1) + (-inf,0) | (1,+inf) + >>> ~(P.open(-P.inf, 0) | P.open(1, P.inf)) + [0,1] + >>> ~P.open(-P.inf, P.inf) + () + + ``` + + - `i.difference(other)` and `i - other` return the difference between `i` and `other`. + ```python + >>> P.closed(0,2) - P.closed(1,2) + [0,1) + >>> P.closed(0, 4) - P.closed(1, 2) + [0,1) | (2,4] + + ``` + + - `i.contains(other)` and `other in i` hold if given item is contained in the interval. + It supports intervals and arbitrary comparable values. + ```python + >>> 2 in P.closed(0, 2) + True + >>> 2 in P.open(0, 2) + False + >>> P.open(0, 1) in P.closed(0, 2) + True + + ``` + + - `i.adjacent(other)` tests if the two intervals are adjacent, i.e., if they do not overlap and their union form a single atomic interval. + While this definition corresponds to the usual notion of adjacency for atomic + intervals, it has stronger requirements for non-atomic ones since it requires + all underlying atomic intervals to be adjacent (i.e. that one + interval fills the gaps between the atomic intervals of the other one). + ```python + >>> P.closed(0, 1).adjacent(P.openclosed(1, 2)) + True + >>> P.closed(0, 1).adjacent(P.closed(1, 2)) + False + >>> (P.closed(0, 1) | P.closed(2, 3)).adjacent(P.open(1, 2) | P.open(3, 4)) + True + >>> (P.closed(0, 1) | P.closed(2, 3)).adjacent(P.open(3, 4)) + False + >>> P.closed(0, 1).adjacent(P.open(1, 2) | P.open(3, 4)) + False + + ``` + + - `i.overlaps(other)` tests if there is an overlap between two intervals. + ```python + >>> P.closed(1, 2).overlaps(P.closed(2, 3)) + True + >>> P.closed(1, 2).overlaps(P.open(2, 3)) + False + + ``` + +Finally, intervals are hashable as long as their bounds are hashable (and we have defined a hash value for `P.inf` and `-P.inf`). + + +[↑ back to top](#table-of-contents) +### Comparison operators + +Equality between intervals can be checked with the classical `==` operator: + +```python +>>> P.closed(0, 2) == P.closed(0, 1) | P.closed(1, 2) +True +>>> P.closed(0, 2) == P.open(0, 2) +False + +``` + +Moreover, intervals are comparable using `>`, `>=`, `<` or `<=`. +These comparison operators have a different behaviour than the usual ones. +For instance, `a < b` holds if all values in `a` are lower than the minimal value of `b` (i.e., `a` is +entirely on the left of the lower bound of `b`). + +```python +>>> P.closed(0, 1) < P.closed(2, 3) +True +>>> P.closed(0, 1) < P.closed(1, 2) +False + +``` + +Similarly, `a <= b` if all values in `a` are lower than the maximal value of `b` (i.e., `a` is +entirely on the left of the upper bound of `b`). + +```python +>>> P.closed(0, 1) <= P.closed(2, 3) +True +>>> P.closed(0, 2) <= P.closed(1, 3) +True +>>> P.closed(0, 3) <= P.closed(1, 2) +False + +``` + +If an interval needs to be compared against a single value, convert the value to a singleton interval first: + +```python +>>> P.singleton(0) < P.closed(0, 10) +False +>>> P.singleton(0) <= P.closed(0, 10) +True +>>> P.singleton(5) <= P.closed(0, 10) +True +>>> P.closed(0, 1) < P.singleton(2) +True + +``` + +Note that all these semantics differ from classical comparison operators. +As a consequence, the empty interval is never `<`, `<=`, `>` nor `>=` than any other interval, and +no interval is `<`, `>`, `<=` or `>=` when compared to the empty interval. + +```python +>>> e = P.empty() +>>> e < e or e > e or e <= e or e >= e +False +>>> i = P.closed(0, 1) +>>> e < i or e <= i or e > i or e >= i +False + +``` + +Moreover, some non-empty intervals are also not comparable in the classical sense, as illustrated hereafter: + +```python +>>> a, b = P.closed(0, 4), P.closed(1, 2) +>>> a < b or a > b +False +>>> a <= b or a >= b +False +>>> b <= a and b >= a +True + +``` + +As a general rule, if `a < b` holds, then `a <= b`, `b > a`, `b >= a`, +`not (a > b)`, `not (b < a)`, `not (a >= b)`, and `not (b <= a)` hold. + + + +[↑ back to top](#table-of-contents) +### Interval transformation + +Intervals are immutable but provide a `replace` method to create a new interval based on the +current one. This method accepts four optional parameters `left`, `lower`, `upper`, and `right`: + +```python +>>> i = P.closed(0, 2) +>>> i.replace(P.OPEN, -1, 3, P.CLOSED) +(-1,3] +>>> i.replace(lower=1, right=P.OPEN) +[1,2) + +``` + +Functions can be passed instead of values. If a function is passed, it is called with the current corresponding +value. + +```python +>>> P.closed(0, 2).replace(upper=lambda x: 2 * x) +[0,4] + +``` + +The provided function won't be called on infinities, unless `ignore_inf` is set to `False`. + +```python +>>> i = P.closedopen(0, P.inf) +>>> i.replace(upper=lambda x: 10) # No change, infinity is ignored +[0,+inf) +>>> i.replace(upper=lambda x: 10, ignore_inf=False) # Infinity is not ignored +[0,10) + +``` + +When `replace` is applied on an interval that is not atomic, it is extended and/or restricted such that +its enclosure satisfies the new bounds. + +```python +>>> i = P.openclosed(0, 1) | P.closed(5, 10) +>>> i.replace(P.CLOSED, -1, 8, P.OPEN) +[-1,1] | [5,8) +>>> i.replace(lower=4) +(4,10] + +``` + +To apply arbitrary transformations on the underlying atomic intervals, intervals expose an `apply` method that acts like `map`. +This method accepts a function that will be applied on each of the underlying atomic intervals to perform the desired transformation. +The provided function is expected to return either an `Interval`, or a 4-uple `(left, lower, upper, right)`. + +```python +>>> i = P.closed(2, 3) | P.open(4, 5) +>>> # Increment bound values +>>> i.apply(lambda x: (x.left, x.lower + 1, x.upper + 1, x.right)) +[3,4] | (5,6) +>>> # Invert bounds +>>> i.apply(lambda x: (~x.left, x.lower, x.upper, ~x.right)) +(2,3) | [4,5] + +``` + +The `apply` method is very powerful when used in combination with `replace`. +Because the latter allows functions to be passed as parameters and ignores infinities by default, it can be +conveniently used to transform (disjunction of) intervals in presence of infinities. + +```python +>>> i = P.openclosed(-P.inf, 0) | P.closed(3, 4) | P.closedopen(8, P.inf) +>>> # Increment bound values +>>> i.apply(lambda x: x.replace(upper=lambda v: v + 1)) +(-inf,1] | [3,5] | [8,+inf) +>>> # Intervals are still automatically simplified +>>> i.apply(lambda x: x.replace(lower=lambda v: v * 2)) +(-inf,0] | [16,+inf) +>>> # Invert bounds +>>> i.apply(lambda x: x.replace(left=lambda v: ~v, right=lambda v: ~v)) +(-inf,0) | (3,4) | (8,+inf) +>>> # Replace infinities with -10 and 10 +>>> conv = lambda v: -10 if v == -P.inf else (10 if v == P.inf else v) +>>> i.apply(lambda x: x.replace(lower=conv, upper=conv, ignore_inf=False)) +(-10,0] | [3,4] | [8,10) + +``` + + +[↑ back to top](#table-of-contents) +### Discrete iteration + +The `iterate` function takes an interval, and returns a generator to iterate over +the values of an interval. Obviously, as intervals are continuous, it is required to specify the + `step` between consecutive values. The iteration then starts from the lower bound and ends on the upper one. Only values contained by the interval are returned this way. + +```python +>>> list(P.iterate(P.closed(0, 3), step=1)) +[0, 1, 2, 3] +>>> list(P.iterate(P.closed(0, 3), step=2)) +[0, 2] +>>> list(P.iterate(P.open(0, 3), step=2)) +[2] + +``` + +When an interval is not atomic, `iterate` consecutively iterates on all underlying atomic +intervals, starting from each lower bound and ending on each upper one: + +```python +>>> list(P.iterate(P.singleton(0) | P.singleton(3) | P.singleton(5), step=2)) # Won't be [0] +[0, 3, 5] +>>> list(P.iterate(P.closed(0, 2) | P.closed(4, 6), step=3)) # Won't be [0, 6] +[0, 4] + +``` + +By default, the iteration always starts on the lower bound of each underlying atomic interval. +The `base` parameter can be used to change this behaviour, by specifying how the initial value to start +the iteration from must be computed. This parameter accepts a callable that is called with the lower +bound of each underlying atomic interval, and that returns the initial value to start the iteration from. +It can be helpful to deal with (semi-)infinite intervals, or to *align* the generated values of the iterator: + +```python +>>> # Align on integers +>>> list(P.iterate(P.closed(0.3, 4.9), step=1, base=int)) +[1, 2, 3, 4] +>>> # Restrict values of a (semi-)infinite interval +>>> list(P.iterate(P.openclosed(-P.inf, 2), step=1, base=lambda x: max(0, x))) +[0, 1, 2] + +``` + +The `base` parameter can be used to change how `iterate` applies on unions of atomic interval, by +specifying a function that returns a single value, as illustrated next: + +```python +>>> base = lambda x: 0 +>>> list(P.iterate(P.singleton(0) | P.singleton(3) | P.singleton(5), step=2, base=base)) +[0] +>>> list(P.iterate(P.closed(0, 2) | P.closed(4, 6), step=3, base=base)) +[0, 6] + +``` + +Notice that defining `base` such that it returns a single value can be extremely inefficient in +terms of performance when the intervals are "far apart" each other (i.e., when the *gaps* between +atomic intervals are large). + +Finally, iteration can be performed in reverse order by specifying `reverse=True`. + +```python +>>> list(P.iterate(P.closed(0, 3), step=-1, reverse=True)) # Mind step=-1 +[3, 2, 1, 0] +>>> list(P.iterate(P.closed(0, 3), step=-2, reverse=True)) # Mind step=-2 +[3, 1] + +``` + +Again, this library does not make any assumption about the objects being used in an interval, as long as they +are comparable. However, it is not always possible to provide a meaningful value for `step` (e.g., what would +be the step between two consecutive characters?). In these cases, a callable can be passed instead of a value. +This callable will be called with the current value, and is expected to return the next possible value. + +```python +>>> list(P.iterate(P.closed('a', 'd'), step=lambda d: chr(ord(d) + 1))) +['a', 'b', 'c', 'd'] +>>> # Since we reversed the order, we changed "+" to "-" in the lambda. +>>> list(P.iterate(P.closed('a', 'd'), step=lambda d: chr(ord(d) - 1), reverse=True)) +['d', 'c', 'b', 'a'] + +``` + + + +[↑ back to top](#table-of-contents) +### Map intervals to data + +The library provides an `IntervalDict` class, a `dict`-like data structure to store and query data +along with intervals. Any value can be stored in such data structure as long as it supports +equality. + + +```python +>>> d = P.IntervalDict() +>>> d[P.closed(0, 3)] = 'banana' +>>> d[4] = 'apple' +>>> d +{[0,3]: 'banana', [4]: 'apple'} + +``` + +When a value is defined for an interval that overlaps an existing one, it is automatically updated +to take the new value into account: + +```python +>>> d[P.closed(2, 4)] = 'orange' +>>> d +{[0,2): 'banana', [2,4]: 'orange'} + +``` + +An `IntervalDict` can be queried using single values or intervals. If a single value is used as a +key, its behaviour corresponds to the one of a classical `dict`: + +```python +>>> d[2] +'orange' +>>> d[5] # Key does not exist +Traceback (most recent call last): + ... +KeyError: 5 +>>> d.get(5, default=0) +0 + +``` + +When the key is an interval, a new `IntervalDict` containing the values +for the specified key is returned: + +```python +>>> d[~P.empty()] # Get all values, similar to d.copy() +{[0,2): 'banana', [2,4]: 'orange'} +>>> d[P.closed(1, 3)] +{[1,2): 'banana', [2,3]: 'orange'} +>>> d[P.closed(-2, 1)] +{[0,1]: 'banana'} +>>> d[P.closed(-2, -1)] +{} + +``` + +By using `.get`, a default value (defaulting to `None`) can be specified. +This value is used to "fill the gaps" if the queried interval is not completely +covered by the `IntervalDict`: + +```python +>>> d.get(P.closed(-2, 1), default='peach') +{[-2,0): 'peach', [0,1]: 'banana'} +>>> d.get(P.closed(-2, -1), default='peach') +{[-2,-1]: 'peach'} +>>> d.get(P.singleton(1), default='peach') # Key is covered, default is not used +{[1]: 'banana'} + +``` + +For convenience, an `IntervalDict` provides a way to look for specific data values. +The `.find` method always returns a (possibly empty) `Interval` instance for which given +value is defined: + +```python +>>> d.find('banana') +[0,2) +>>> d.find('orange') +[2,4] +>>> d.find('carrot') +() + +``` + +The active domain of an `IntervalDict` can be retrieved with its `.domain` method. +This method always returns a single `Interval` instance, where `.keys` returns a sorted view of disjoint intervals. + +```python +>>> d.domain() +[0,4] +>>> list(d.keys()) +[[0,2), [2,4]] +>>> list(d.values()) +['banana', 'orange'] +>>> list(d.items()) +[([0,2), 'banana'), ([2,4], 'orange')] + +``` + +The `.keys`, `.values` and `.items` methods return exactly one element for each stored value (i.e., if two intervals share a value, they are merged into a disjunction), as illustrated next. +See [#44](https://github.com/AlexandreDecan/portion/issues/44#issuecomment-710199687) to know how to obtained a sorted list of atomic intervals instead. + +```python +>>> d = P.IntervalDict() +>>> d[P.closed(0, 1)] = d[P.closed(2, 3)] = 'peach' +>>> list(d.items()) +[([0,1] | [2,3], 'peach')] + +``` + + +Two `IntervalDict` instances can be combined together using the `.combine` method. +This method returns a new `IntervalDict` whose keys and values are taken from the two +source `IntervalDict`. Values corresponding to non-intersecting keys are simply copied, +while values corresponding to intersecting keys are combined together using the provided +function, as illustrated hereafter: + +```python +>>> d1 = P.IntervalDict({P.closed(0, 2): 'banana'}) +>>> d2 = P.IntervalDict({P.closed(1, 3): 'orange'}) +>>> concat = lambda x, y: x + '/' + y +>>> d1.combine(d2, how=concat) +{[0,1): 'banana', [1,2]: 'banana/orange', (2,3]: 'orange'} + +``` + +Resulting keys always correspond to an outer join. Other joins can be easily simulated +by querying the resulting `IntervalDict` as follows: + +```python +>>> d = d1.combine(d2, how=concat) +>>> d[d1.domain()] # Left join +{[0,1): 'banana', [1,2]: 'banana/orange'} +>>> d[d2.domain()] # Right join +{[1,2]: 'banana/orange', (2,3]: 'orange'} +>>> d[d1.domain() & d2.domain()] # Inner join +{[1,2]: 'banana/orange'} + +``` + +Finally, similarly to a `dict`, an `IntervalDict` also supports `len`, `in` and `del`, and defines +`.clear`, `.copy`, `.update`, `.pop`, `.popitem`, and `.setdefault`. +For convenience, one can export the content of an `IntervalDict` to a classical Python `dict` using +the `as_dict` method. This method accepts an optional `atomic` parameter (whose default is `False`). When set to `True`, the keys of the resulting `dict` instance are atomic intervals. + + +[↑ back to top](#table-of-contents) +### Import & export intervals to strings + +Intervals can be exported to string, either using `repr` (as illustrated above) or with the `to_string` function. + +```python +>>> P.to_string(P.closedopen(0, 1)) +'[0,1)' + +``` + +The way string representations are built can be easily parametrized using the various parameters supported by +`to_string`: + +```python +>>> params = { +... 'disj': ' or ', +... 'sep': ' - ', +... 'left_closed': '<', +... 'right_closed': '>', +... 'left_open': '..', +... 'right_open': '..', +... 'pinf': '+oo', +... 'ninf': '-oo', +... 'conv': lambda v: '"{}"'.format(v), +... } +>>> x = P.openclosed(0, 1) | P.closed(2, P.inf) +>>> P.to_string(x, **params) +'.."0" - "1"> or <"2" - +oo..' + +``` + +Similarly, intervals can be created from a string using the `from_string` function. +A conversion function (`conv` parameter) has to be provided to convert a bound (as string) to a value. + +```python +>>> P.from_string('[0, 1]', conv=int) == P.closed(0, 1) +True +>>> P.from_string('[1.2]', conv=float) == P.singleton(1.2) +True +>>> converter = lambda s: datetime.datetime.strptime(s, '%Y/%m/%d') +>>> P.from_string('[2011/03/15, 2013/10/10]', conv=converter) +[datetime.datetime(2011, 3, 15, 0, 0),datetime.datetime(2013, 10, 10, 0, 0)] + +``` + +Similarly to `to_string`, function `from_string` can be parametrized to deal with more elaborated inputs. +Notice that as `from_string` expects regular expression patterns, we need to escape some characters. + +```python +>>> s = '.."0" - "1"> or <"2" - +oo..' +>>> params = { +... 'disj': ' or ', +... 'sep': ' - ', +... 'left_closed': '<', +... 'right_closed': '>', +... 'left_open': r'\.\.', # from_string expects regular expression patterns +... 'right_open': r'\.\.', # from_string expects regular expression patterns +... 'pinf': r'\+oo', # from_string expects regular expression patterns +... 'ninf': '-oo', +... 'conv': lambda v: int(v[1:-1]), +... } +>>> P.from_string(s, **params) +(0,1] | [2,+inf) + +``` + +When a bound contains a comma or has a representation that cannot be automatically parsed with `from_string`, +the `bound` parameter can be used to specify the regular expression that should be used to match its representation. + +```python +>>> s = '[(0, 1), (2, 3)]' # Bounds are expected to be tuples +>>> P.from_string(s, conv=eval, bound=r'\(.+?\)') +[(0, 1),(2, 3)] + +``` + + +[↑ back to top](#table-of-contents) +### Import & export intervals to Python built-in data types + +Intervals can also be exported to a list of 4-uples with `to_data`, e.g., to support JSON serialization. +`P.CLOSED` and `P.OPEN` are represented by Boolean values `True` (inclusive) and `False` (exclusive). + +```python +>>> P.to_data(P.openclosed(0, 2)) +[(False, 0, 2, True)] + +``` + +The values used to represent positive and negative infinities can be specified with +`pinf` and `ninf`. They default to `float('inf')` and `float('-inf')` respectively. + +```python +>>> x = P.openclosed(0, 1) | P.closedopen(2, P.inf) +>>> P.to_data(x) +[(False, 0, 1, True), (True, 2, inf, False)] + +``` + +The function to convert bounds can be specified with the `conv` parameter. + +```python +>>> x = P.closedopen(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10)) +>>> P.to_data(x, conv=lambda v: (v.year, v.month, v.day)) +[(True, (2011, 3, 15), (2013, 10, 10), False)] + +``` + +Intervals can be imported from such a list of 4-tuples with `from_data`. +The same set of parameters can be used to specify how bounds and infinities are converted. + +```python +>>> x = [(True, (2011, 3, 15), (2013, 10, 10), False)] +>>> P.from_data(x, conv=lambda v: datetime.date(*v)) +[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)) + +``` + + +[↑ back to top](#table-of-contents) +### Specialize & customize intervals + +**Disclaimer**: the features explained in this section are still experimental and are subject to backward incompatible changes even in minor or patch updates of `portion`. + +The intervals provided by `portion` already cover a wide range of use cases. +However, in some situations, it might be interesting to specialize or customize these intervals. +One typical example would be to support discrete intervals such as intervals of integers. + +While it is definitely possible to rely on the default intervals provided by `portion` to encode discrete +intervals, there are a few edge cases that lead some operations to return unexpected results: + +```python +>>> P.singleton(0) | P.singleton(1) # Case 1: should be [0,1] for discrete numbers +[0] | [1] +>>> P.open(0, 1) # Case 2: should be empty +(0,1) +>>> P.closedopen(0, 1) # Case 3: should be singleton [0] +[0,1) + +``` + +The `portion` library makes its best to ease defining and using subclasses of `Interval` to address +these situations. In particular, `Interval` instances always produce new intervals using `self.__class__`, and the class is written in a way that most of its methods can be easily extended. + +To implement a class for intervals of discrete numbers and to cover the three aforementioned cases, we need to change the behaviour of the `Interval._mergeable` class method (to address first case) and of the `Interval.from_atomic` class method (for cases 2 and 3). +The former is used to detect whether two atomic intervals can be merged into a single interval, while the latter is used to create atomic intervals. + +Thankfully, since discrete intervals are expected to be a frequent use case, `portion` provides an `AbstractDiscreteInterval` class that already makes the appropriate changes to these two methods. +As indicated by its name, this class cannot be used directly and should be inherited. +In particular, one has either to provide a `_step` class attribute to define the step between consecutive discrete values, or to define the `_incr` and `_decr` class methods: + +```python +>>> class IntInterval(P.AbstractDiscreteInterval): +... _step = 1 + +``` + +That's all! +We can now use this class to manipulate intervals of discrete numbers and see it covers the three problematic cases: + +```python +>>> IntInterval.from_atomic(P.CLOSED, 0, 0, P.CLOSED) | IntInterval.from_atomic(P.CLOSED, 1, 1, P.CLOSED) +[0,1] +>>> IntInterval.from_atomic(P.OPEN, 0, 1, P.OPEN) +() +>>> IntInterval.from_atomic(P.CLOSED, 0, 1, P.OPEN) +[0] + +``` + +As an example of using `_incr` and `_decr`, consider the following `CharInterval` subclass tailored to manipulate intervals of characters: + +```python +>>> class CharInterval(P.AbstractDiscreteInterval): +... _incr = lambda v: chr(ord(v) + 1) +... _decr = lambda v: chr(ord(v) - 1) +>>> CharInterval.from_atomic(P.OPEN, 'a', 'z', P.OPEN) +['b','y'] + +``` + +Having to call `from_atomic` on the subclass to create intervals is quite verbose. +For convenience, all the functions that create interval instances accept an additional `klass` parameter to specify the class that creates intervals, circumventing the direct use of the class constructors. +However, having to specify the `klass` parameter in each call to `P.closed` or other helpers that create intervals is still a bit too verbose to be convenient. +Consequently, `portion` provides a `create_api` function that, given a subclass of `Interval`, returns a dynamically generated module whose API is similar to the one of `portion` but configured to use the subclass instead: + +```python +>>> D = P.create_api(IntInterval) +>>> D.singleton(0) | D.singleton(1) +[0,1] +>>> D.open(0, 1) +() +>>> D.closedopen(0, 1) +[0] + +``` + +This makes it easy to use our newly defined `IntInterval` subclass while still benefiting from `portion`'s API. + +Let's extend our example to support intervals of natural numbers. +Such intervals are quite similar to the above ones, except they cannot go over negative values. +We can prevent the bounds of an interval to be negative by slightly changing the `from_atomic` class method as follows: + +```python +>>> class NaturalInterval(IntInterval): +... @classmethod +... def from_atomic(cls, left, lower, upper, right): +... return super().from_atomic( +... P.CLOSED if lower < 0 else left, +... max(0, lower), +... upper, +... right, +... ) + +``` + +We can now define and use the `N` module to check whether our newly defined `NaturalInterval` does the job: + +```python +>>> N = P.create_api(NaturalInterval) +>>> N.closed(-10, 2) +[0,2] +>>> N.open(-10, 2) +[0,1] +>>> ~N.empty() +[0,+inf) + +``` + +Keep in mind that just because `NaturalInterval` has semantics associated with natural numbers does not mean that all possible operations on these intervals strictly comply the semantics. +The following examples illustrate some of the cases where additional checks should be implemented to strictly adhere to these semantics: + +```python +>>> N.closed(1.5, 2.5) # Bounds are not natural numbers +[1.5,2.5] +>>> 0.5 in N.closed(0, 1) # Given value is not a natural number +True +>>> ~N.singleton(0.5) +[1.5,+inf) + +``` + + + +[↑ back to top](#table-of-contents) +## Changelog + +This library adheres to a [semantic versioning](https://semver.org) scheme. +See [CHANGELOG.md](https://github.com/AlexandreDecan/portion/blob/master/CHANGELOG.md) for the list of changes. + + + +## Contributions + +Contributions are very welcome! +Feel free to report bugs or suggest new features using GitHub issues and/or pull requests. + + + +## License + +Distributed under [LGPLv3 - GNU Lesser General Public License, version 3](https://github.com/AlexandreDecan/portion/blob/master/LICENSE.txt). + +You can refer to this library using: + +``` +@software{portion, + author = {Decan, Alexandre}, + title = {portion: Python data structure and operations for intervals}, + url = {https://github.com/AlexandreDecan/portion}, +} +``` + + + + +%package help +Summary: Development documents and examples for portion +Provides: python3-portion-doc +%description help +# portion - data structure and operations for intervals + +[![Tests](https://github.com/AlexandreDecan/portion/actions/workflows/test.yaml/badge.svg?branch=master)](https://github.com/AlexandreDecan/portion/actions/workflows/test.yaml) +[![Coverage Status](https://coveralls.io/repos/github/AlexandreDecan/portion/badge.svg?branch=master)](https://coveralls.io/github/AlexandreDecan/portion?branch=master) +[![License](https://badgen.net/pypi/license/portion)](https://github.com/AlexandreDecan/portion/blob/master/LICENSE.txt) +[![PyPI](https://badgen.net/pypi/v/portion)](https://pypi.org/project/portion) +[![Commits](https://badgen.net/github/last-commit/AlexandreDecan/portion)](https://github.com/AlexandreDecan/portion/commits/) + + +The `portion` library provides data structure and operations for intervals in Python 3.7+. + + - Support intervals of any (comparable) objects. + - Closed or open, finite or (semi-)infinite intervals. + - Interval sets (union of atomic intervals) are supported. + - Automatic simplification of intervals. + - Support comparison, transformation, intersection, union, complement, difference and containment. + - Provide test for emptiness, atomicity, overlap and adjacency. + - Discrete iterations on the values of an interval. + - Dict-like structure to map intervals to data. + - Import and export intervals to strings and to Python built-in data types. + - Heavily tested with high code coverage. + + +## Table of contents + + * [Installation](#installation) + * [Documentation & usage](#documentation--usage) + * [Interval creation](#interval-creation) + * [Interval bounds & attributes](#interval-bounds--attributes) + * [Interval operations](#interval-operations) + * [Comparison operators](#comparison-operators) + * [Interval transformation](#interval-transformation) + * [Discrete iteration](#discrete-iteration) + * [Map intervals to data](#map-intervals-to-data) + * [Import & export intervals to strings](#import--export-intervals-to-strings) + * [Import & export intervals to Python built-in data types](#import--export-intervals-to-python-built-in-data-types) + * [Specialize & customize intervals](#specialize--customize-intervals) + * [Changelog](#changelog) + * [Contributions](#contributions) + * [License](#license) + + +## Installation + +You can use `pip` to install it, as usual: `pip install portion`. This will install the latest available version from [PyPI](https://pypi.org/project/portion). +Pre-releases are available from the *master* branch on [GitHub](https://github.com/AlexandreDecan/portion) +and can be installed with `pip install git+https://github.com/AlexandreDecan/portion`. +Note that `portion` is also available on [conda-forge](https://anaconda.org/conda-forge/portion). + +You can install `portion` and its development environment using `pip install -e .[test]` at the root of this repository. This automatically installs [pytest](https://docs.pytest.org/en/latest/) (for the test suites) and [black](https://black.readthedocs.io/en/stable/) (for code formatting). + + +## Documentation & usage + +### Interval creation + +Assuming this library is imported using `import portion as P`, intervals can be easily +created using one of the following helpers: + +```python +>>> P.open(1, 2) +(1,2) +>>> P.closed(1, 2) +[1,2] +>>> P.openclosed(1, 2) +(1,2] +>>> P.closedopen(1, 2) +[1,2) +>>> P.singleton(1) +[1] +>>> P.empty() +() + +``` + +The bounds of an interval can be any arbitrary values, as long as they are comparable: + +```python +>>> P.closed(1.2, 2.4) +[1.2,2.4] +>>> P.closed('a', 'z') +['a','z'] +>>> import datetime +>>> P.closed(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10)) +[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)] + +``` + + +Infinite and semi-infinite intervals are supported using `P.inf` and `-P.inf` as upper or lower bounds. +These two objects support comparison with any other object. +When infinities are used as a lower or upper bound, the corresponding boundary is automatically converted to an open one. + +```python +>>> P.inf > 'a', P.inf > 0, P.inf > True +(True, True, True) +>>> P.openclosed(-P.inf, 0) +(-inf,0] +>>> P.closed(-P.inf, P.inf) # Automatically converted to an open interval +(-inf,+inf) + +``` + +Intervals created with this library are `Interval` instances. +An `Interval` instance is a disjunction of atomic intervals each representing a single interval (e.g. `[1,2]`). +Intervals can be iterated to access the underlying atomic intervals, sorted by their lower and upper bounds. + +```python +>>> list(P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21)) +[[0,1], (10,11), [20,21]] +>>> list(P.empty()) +[] + +``` + +Nested (sorted) intervals can also be retrieved with a position or a slice: + +```python +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[0] +[0,1] +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[-2] +(10,11) +>>> (P.open(10, 11) | P.closed(0, 1) | P.closed(20, 21))[:2] +[0,1] | (10,11) + +``` + +For convenience, intervals are automatically simplified: + +```python +>>> P.closed(0, 2) | P.closed(2, 4) +[0,4] +>>> P.closed(1, 2) | P.closed(3, 4) | P.closed(2, 3) +[1,4] +>>> P.empty() | P.closed(0, 1) +[0,1] +>>> P.closed(1, 2) | P.closed(2, 3) | P.closed(4, 5) +[1,3] | [4,5] + +``` + +Note that, by default, simplification of discrete intervals is **not** supported by `portion` (but it can be simulated though, see [#24](https://github.com/AlexandreDecan/portion/issues/24#issuecomment-604456362)). +For example, combining `[0,1]` with `[2,3]` will **not** result in `[0,3]` even if there is +no integer between `1` and `2`. +Refer to [Specialize & customize intervals](#specialize--customize-intervals) to see how to create and use specialized discrete intervals. + + + +[↑ back to top](#table-of-contents) +### Interval bounds & attributes + + +An `Interval` defines the following properties: + + - `i.empty` is `True` if and only if the interval is empty. + ```python + >>> P.closed(0, 1).empty + False + >>> P.closed(0, 0).empty + False + >>> P.openclosed(0, 0).empty + True + >>> P.empty().empty + True + + ``` + + - `i.atomic` is `True` if and only if the interval is empty or is a disjunction of a single interval. + ```python + >>> P.empty().atomic + True + >>> P.closed(0, 2).atomic + True + >>> (P.closed(0, 1) | P.closed(1, 2)).atomic + True + >>> (P.closed(0, 1) | P.closed(2, 3)).atomic + False + + ``` + + - `i.enclosure` refers to the smallest atomic interval that includes the current one. + ```python + >>> (P.closed(0, 1) | P.open(2, 3)).enclosure + [0,3) + + ``` + +The left and right boundaries, and the lower and upper bounds of an interval can be respectively accessed +with its `left`, `right`, `lower` and `upper` attributes. +The `left` and `right` bounds are either `P.CLOSED` or `P.OPEN`. +By definition, `P.CLOSED == ~P.OPEN` and vice-versa. + +```python +>> P.CLOSED, P.OPEN +CLOSED, OPEN +>>> x = P.closedopen(0, 1) +>>> x.left, x.lower, x.upper, x.right +(CLOSED, 0, 1, OPEN) + +``` + +By convention, empty intervals resolve to `(P.inf, -P.inf)`: + +```python +>>> i = P.empty() +>>> i.left, i.lower, i.upper, i.right +(OPEN, +inf, -inf, OPEN) + +``` + + +If the interval is not atomic, then `left` and `lower` refer to the lower bound of its enclosure, +while `right` and `upper` refer to the upper bound of its enclosure: + +```python +>>> x = P.open(0, 1) | P.closed(3, 4) +>>> x.left, x.lower, x.upper, x.right +(OPEN, 0, 4, CLOSED) + +``` + +One can easily check for some interval properties based on the bounds of an interval: + +```python +>>> x = P.openclosed(-P.inf, 0) +>>> # Check that interval is left/right closed +>>> x.left == P.CLOSED, x.right == P.CLOSED +(False, True) +>>> # Check that interval is left/right bounded +>>> x.lower == -P.inf, x.upper == P.inf +(True, False) +>>> # Check for singleton +>>> x.lower == x.upper +False + +``` + + + +[↑ back to top](#table-of-contents) +### Interval operations + +`Interval` instances support the following operations: + + - `i.intersection(other)` and `i & other` return the intersection of two intervals. + ```python + >>> P.closed(0, 2) & P.closed(1, 3) + [1,2] + >>> P.closed(0, 4) & P.open(2, 3) + (2,3) + >>> P.closed(0, 2) & P.closed(2, 3) + [2] + >>> P.closed(0, 2) & P.closed(3, 4) + () + + ``` + + - `i.union(other)` and `i | other` return the union of two intervals. + ```python + >>> P.closed(0, 1) | P.closed(1, 2) + [0,2] + >>> P.closed(0, 1) | P.closed(2, 3) + [0,1] | [2,3] + + ``` + + - `i.complement(other)` and `~i` return the complement of the interval. + ```python + >>> ~P.closed(0, 1) + (-inf,0) | (1,+inf) + >>> ~(P.open(-P.inf, 0) | P.open(1, P.inf)) + [0,1] + >>> ~P.open(-P.inf, P.inf) + () + + ``` + + - `i.difference(other)` and `i - other` return the difference between `i` and `other`. + ```python + >>> P.closed(0,2) - P.closed(1,2) + [0,1) + >>> P.closed(0, 4) - P.closed(1, 2) + [0,1) | (2,4] + + ``` + + - `i.contains(other)` and `other in i` hold if given item is contained in the interval. + It supports intervals and arbitrary comparable values. + ```python + >>> 2 in P.closed(0, 2) + True + >>> 2 in P.open(0, 2) + False + >>> P.open(0, 1) in P.closed(0, 2) + True + + ``` + + - `i.adjacent(other)` tests if the two intervals are adjacent, i.e., if they do not overlap and their union form a single atomic interval. + While this definition corresponds to the usual notion of adjacency for atomic + intervals, it has stronger requirements for non-atomic ones since it requires + all underlying atomic intervals to be adjacent (i.e. that one + interval fills the gaps between the atomic intervals of the other one). + ```python + >>> P.closed(0, 1).adjacent(P.openclosed(1, 2)) + True + >>> P.closed(0, 1).adjacent(P.closed(1, 2)) + False + >>> (P.closed(0, 1) | P.closed(2, 3)).adjacent(P.open(1, 2) | P.open(3, 4)) + True + >>> (P.closed(0, 1) | P.closed(2, 3)).adjacent(P.open(3, 4)) + False + >>> P.closed(0, 1).adjacent(P.open(1, 2) | P.open(3, 4)) + False + + ``` + + - `i.overlaps(other)` tests if there is an overlap between two intervals. + ```python + >>> P.closed(1, 2).overlaps(P.closed(2, 3)) + True + >>> P.closed(1, 2).overlaps(P.open(2, 3)) + False + + ``` + +Finally, intervals are hashable as long as their bounds are hashable (and we have defined a hash value for `P.inf` and `-P.inf`). + + +[↑ back to top](#table-of-contents) +### Comparison operators + +Equality between intervals can be checked with the classical `==` operator: + +```python +>>> P.closed(0, 2) == P.closed(0, 1) | P.closed(1, 2) +True +>>> P.closed(0, 2) == P.open(0, 2) +False + +``` + +Moreover, intervals are comparable using `>`, `>=`, `<` or `<=`. +These comparison operators have a different behaviour than the usual ones. +For instance, `a < b` holds if all values in `a` are lower than the minimal value of `b` (i.e., `a` is +entirely on the left of the lower bound of `b`). + +```python +>>> P.closed(0, 1) < P.closed(2, 3) +True +>>> P.closed(0, 1) < P.closed(1, 2) +False + +``` + +Similarly, `a <= b` if all values in `a` are lower than the maximal value of `b` (i.e., `a` is +entirely on the left of the upper bound of `b`). + +```python +>>> P.closed(0, 1) <= P.closed(2, 3) +True +>>> P.closed(0, 2) <= P.closed(1, 3) +True +>>> P.closed(0, 3) <= P.closed(1, 2) +False + +``` + +If an interval needs to be compared against a single value, convert the value to a singleton interval first: + +```python +>>> P.singleton(0) < P.closed(0, 10) +False +>>> P.singleton(0) <= P.closed(0, 10) +True +>>> P.singleton(5) <= P.closed(0, 10) +True +>>> P.closed(0, 1) < P.singleton(2) +True + +``` + +Note that all these semantics differ from classical comparison operators. +As a consequence, the empty interval is never `<`, `<=`, `>` nor `>=` than any other interval, and +no interval is `<`, `>`, `<=` or `>=` when compared to the empty interval. + +```python +>>> e = P.empty() +>>> e < e or e > e or e <= e or e >= e +False +>>> i = P.closed(0, 1) +>>> e < i or e <= i or e > i or e >= i +False + +``` + +Moreover, some non-empty intervals are also not comparable in the classical sense, as illustrated hereafter: + +```python +>>> a, b = P.closed(0, 4), P.closed(1, 2) +>>> a < b or a > b +False +>>> a <= b or a >= b +False +>>> b <= a and b >= a +True + +``` + +As a general rule, if `a < b` holds, then `a <= b`, `b > a`, `b >= a`, +`not (a > b)`, `not (b < a)`, `not (a >= b)`, and `not (b <= a)` hold. + + + +[↑ back to top](#table-of-contents) +### Interval transformation + +Intervals are immutable but provide a `replace` method to create a new interval based on the +current one. This method accepts four optional parameters `left`, `lower`, `upper`, and `right`: + +```python +>>> i = P.closed(0, 2) +>>> i.replace(P.OPEN, -1, 3, P.CLOSED) +(-1,3] +>>> i.replace(lower=1, right=P.OPEN) +[1,2) + +``` + +Functions can be passed instead of values. If a function is passed, it is called with the current corresponding +value. + +```python +>>> P.closed(0, 2).replace(upper=lambda x: 2 * x) +[0,4] + +``` + +The provided function won't be called on infinities, unless `ignore_inf` is set to `False`. + +```python +>>> i = P.closedopen(0, P.inf) +>>> i.replace(upper=lambda x: 10) # No change, infinity is ignored +[0,+inf) +>>> i.replace(upper=lambda x: 10, ignore_inf=False) # Infinity is not ignored +[0,10) + +``` + +When `replace` is applied on an interval that is not atomic, it is extended and/or restricted such that +its enclosure satisfies the new bounds. + +```python +>>> i = P.openclosed(0, 1) | P.closed(5, 10) +>>> i.replace(P.CLOSED, -1, 8, P.OPEN) +[-1,1] | [5,8) +>>> i.replace(lower=4) +(4,10] + +``` + +To apply arbitrary transformations on the underlying atomic intervals, intervals expose an `apply` method that acts like `map`. +This method accepts a function that will be applied on each of the underlying atomic intervals to perform the desired transformation. +The provided function is expected to return either an `Interval`, or a 4-uple `(left, lower, upper, right)`. + +```python +>>> i = P.closed(2, 3) | P.open(4, 5) +>>> # Increment bound values +>>> i.apply(lambda x: (x.left, x.lower + 1, x.upper + 1, x.right)) +[3,4] | (5,6) +>>> # Invert bounds +>>> i.apply(lambda x: (~x.left, x.lower, x.upper, ~x.right)) +(2,3) | [4,5] + +``` + +The `apply` method is very powerful when used in combination with `replace`. +Because the latter allows functions to be passed as parameters and ignores infinities by default, it can be +conveniently used to transform (disjunction of) intervals in presence of infinities. + +```python +>>> i = P.openclosed(-P.inf, 0) | P.closed(3, 4) | P.closedopen(8, P.inf) +>>> # Increment bound values +>>> i.apply(lambda x: x.replace(upper=lambda v: v + 1)) +(-inf,1] | [3,5] | [8,+inf) +>>> # Intervals are still automatically simplified +>>> i.apply(lambda x: x.replace(lower=lambda v: v * 2)) +(-inf,0] | [16,+inf) +>>> # Invert bounds +>>> i.apply(lambda x: x.replace(left=lambda v: ~v, right=lambda v: ~v)) +(-inf,0) | (3,4) | (8,+inf) +>>> # Replace infinities with -10 and 10 +>>> conv = lambda v: -10 if v == -P.inf else (10 if v == P.inf else v) +>>> i.apply(lambda x: x.replace(lower=conv, upper=conv, ignore_inf=False)) +(-10,0] | [3,4] | [8,10) + +``` + + +[↑ back to top](#table-of-contents) +### Discrete iteration + +The `iterate` function takes an interval, and returns a generator to iterate over +the values of an interval. Obviously, as intervals are continuous, it is required to specify the + `step` between consecutive values. The iteration then starts from the lower bound and ends on the upper one. Only values contained by the interval are returned this way. + +```python +>>> list(P.iterate(P.closed(0, 3), step=1)) +[0, 1, 2, 3] +>>> list(P.iterate(P.closed(0, 3), step=2)) +[0, 2] +>>> list(P.iterate(P.open(0, 3), step=2)) +[2] + +``` + +When an interval is not atomic, `iterate` consecutively iterates on all underlying atomic +intervals, starting from each lower bound and ending on each upper one: + +```python +>>> list(P.iterate(P.singleton(0) | P.singleton(3) | P.singleton(5), step=2)) # Won't be [0] +[0, 3, 5] +>>> list(P.iterate(P.closed(0, 2) | P.closed(4, 6), step=3)) # Won't be [0, 6] +[0, 4] + +``` + +By default, the iteration always starts on the lower bound of each underlying atomic interval. +The `base` parameter can be used to change this behaviour, by specifying how the initial value to start +the iteration from must be computed. This parameter accepts a callable that is called with the lower +bound of each underlying atomic interval, and that returns the initial value to start the iteration from. +It can be helpful to deal with (semi-)infinite intervals, or to *align* the generated values of the iterator: + +```python +>>> # Align on integers +>>> list(P.iterate(P.closed(0.3, 4.9), step=1, base=int)) +[1, 2, 3, 4] +>>> # Restrict values of a (semi-)infinite interval +>>> list(P.iterate(P.openclosed(-P.inf, 2), step=1, base=lambda x: max(0, x))) +[0, 1, 2] + +``` + +The `base` parameter can be used to change how `iterate` applies on unions of atomic interval, by +specifying a function that returns a single value, as illustrated next: + +```python +>>> base = lambda x: 0 +>>> list(P.iterate(P.singleton(0) | P.singleton(3) | P.singleton(5), step=2, base=base)) +[0] +>>> list(P.iterate(P.closed(0, 2) | P.closed(4, 6), step=3, base=base)) +[0, 6] + +``` + +Notice that defining `base` such that it returns a single value can be extremely inefficient in +terms of performance when the intervals are "far apart" each other (i.e., when the *gaps* between +atomic intervals are large). + +Finally, iteration can be performed in reverse order by specifying `reverse=True`. + +```python +>>> list(P.iterate(P.closed(0, 3), step=-1, reverse=True)) # Mind step=-1 +[3, 2, 1, 0] +>>> list(P.iterate(P.closed(0, 3), step=-2, reverse=True)) # Mind step=-2 +[3, 1] + +``` + +Again, this library does not make any assumption about the objects being used in an interval, as long as they +are comparable. However, it is not always possible to provide a meaningful value for `step` (e.g., what would +be the step between two consecutive characters?). In these cases, a callable can be passed instead of a value. +This callable will be called with the current value, and is expected to return the next possible value. + +```python +>>> list(P.iterate(P.closed('a', 'd'), step=lambda d: chr(ord(d) + 1))) +['a', 'b', 'c', 'd'] +>>> # Since we reversed the order, we changed "+" to "-" in the lambda. +>>> list(P.iterate(P.closed('a', 'd'), step=lambda d: chr(ord(d) - 1), reverse=True)) +['d', 'c', 'b', 'a'] + +``` + + + +[↑ back to top](#table-of-contents) +### Map intervals to data + +The library provides an `IntervalDict` class, a `dict`-like data structure to store and query data +along with intervals. Any value can be stored in such data structure as long as it supports +equality. + + +```python +>>> d = P.IntervalDict() +>>> d[P.closed(0, 3)] = 'banana' +>>> d[4] = 'apple' +>>> d +{[0,3]: 'banana', [4]: 'apple'} + +``` + +When a value is defined for an interval that overlaps an existing one, it is automatically updated +to take the new value into account: + +```python +>>> d[P.closed(2, 4)] = 'orange' +>>> d +{[0,2): 'banana', [2,4]: 'orange'} + +``` + +An `IntervalDict` can be queried using single values or intervals. If a single value is used as a +key, its behaviour corresponds to the one of a classical `dict`: + +```python +>>> d[2] +'orange' +>>> d[5] # Key does not exist +Traceback (most recent call last): + ... +KeyError: 5 +>>> d.get(5, default=0) +0 + +``` + +When the key is an interval, a new `IntervalDict` containing the values +for the specified key is returned: + +```python +>>> d[~P.empty()] # Get all values, similar to d.copy() +{[0,2): 'banana', [2,4]: 'orange'} +>>> d[P.closed(1, 3)] +{[1,2): 'banana', [2,3]: 'orange'} +>>> d[P.closed(-2, 1)] +{[0,1]: 'banana'} +>>> d[P.closed(-2, -1)] +{} + +``` + +By using `.get`, a default value (defaulting to `None`) can be specified. +This value is used to "fill the gaps" if the queried interval is not completely +covered by the `IntervalDict`: + +```python +>>> d.get(P.closed(-2, 1), default='peach') +{[-2,0): 'peach', [0,1]: 'banana'} +>>> d.get(P.closed(-2, -1), default='peach') +{[-2,-1]: 'peach'} +>>> d.get(P.singleton(1), default='peach') # Key is covered, default is not used +{[1]: 'banana'} + +``` + +For convenience, an `IntervalDict` provides a way to look for specific data values. +The `.find` method always returns a (possibly empty) `Interval` instance for which given +value is defined: + +```python +>>> d.find('banana') +[0,2) +>>> d.find('orange') +[2,4] +>>> d.find('carrot') +() + +``` + +The active domain of an `IntervalDict` can be retrieved with its `.domain` method. +This method always returns a single `Interval` instance, where `.keys` returns a sorted view of disjoint intervals. + +```python +>>> d.domain() +[0,4] +>>> list(d.keys()) +[[0,2), [2,4]] +>>> list(d.values()) +['banana', 'orange'] +>>> list(d.items()) +[([0,2), 'banana'), ([2,4], 'orange')] + +``` + +The `.keys`, `.values` and `.items` methods return exactly one element for each stored value (i.e., if two intervals share a value, they are merged into a disjunction), as illustrated next. +See [#44](https://github.com/AlexandreDecan/portion/issues/44#issuecomment-710199687) to know how to obtained a sorted list of atomic intervals instead. + +```python +>>> d = P.IntervalDict() +>>> d[P.closed(0, 1)] = d[P.closed(2, 3)] = 'peach' +>>> list(d.items()) +[([0,1] | [2,3], 'peach')] + +``` + + +Two `IntervalDict` instances can be combined together using the `.combine` method. +This method returns a new `IntervalDict` whose keys and values are taken from the two +source `IntervalDict`. Values corresponding to non-intersecting keys are simply copied, +while values corresponding to intersecting keys are combined together using the provided +function, as illustrated hereafter: + +```python +>>> d1 = P.IntervalDict({P.closed(0, 2): 'banana'}) +>>> d2 = P.IntervalDict({P.closed(1, 3): 'orange'}) +>>> concat = lambda x, y: x + '/' + y +>>> d1.combine(d2, how=concat) +{[0,1): 'banana', [1,2]: 'banana/orange', (2,3]: 'orange'} + +``` + +Resulting keys always correspond to an outer join. Other joins can be easily simulated +by querying the resulting `IntervalDict` as follows: + +```python +>>> d = d1.combine(d2, how=concat) +>>> d[d1.domain()] # Left join +{[0,1): 'banana', [1,2]: 'banana/orange'} +>>> d[d2.domain()] # Right join +{[1,2]: 'banana/orange', (2,3]: 'orange'} +>>> d[d1.domain() & d2.domain()] # Inner join +{[1,2]: 'banana/orange'} + +``` + +Finally, similarly to a `dict`, an `IntervalDict` also supports `len`, `in` and `del`, and defines +`.clear`, `.copy`, `.update`, `.pop`, `.popitem`, and `.setdefault`. +For convenience, one can export the content of an `IntervalDict` to a classical Python `dict` using +the `as_dict` method. This method accepts an optional `atomic` parameter (whose default is `False`). When set to `True`, the keys of the resulting `dict` instance are atomic intervals. + + +[↑ back to top](#table-of-contents) +### Import & export intervals to strings + +Intervals can be exported to string, either using `repr` (as illustrated above) or with the `to_string` function. + +```python +>>> P.to_string(P.closedopen(0, 1)) +'[0,1)' + +``` + +The way string representations are built can be easily parametrized using the various parameters supported by +`to_string`: + +```python +>>> params = { +... 'disj': ' or ', +... 'sep': ' - ', +... 'left_closed': '<', +... 'right_closed': '>', +... 'left_open': '..', +... 'right_open': '..', +... 'pinf': '+oo', +... 'ninf': '-oo', +... 'conv': lambda v: '"{}"'.format(v), +... } +>>> x = P.openclosed(0, 1) | P.closed(2, P.inf) +>>> P.to_string(x, **params) +'.."0" - "1"> or <"2" - +oo..' + +``` + +Similarly, intervals can be created from a string using the `from_string` function. +A conversion function (`conv` parameter) has to be provided to convert a bound (as string) to a value. + +```python +>>> P.from_string('[0, 1]', conv=int) == P.closed(0, 1) +True +>>> P.from_string('[1.2]', conv=float) == P.singleton(1.2) +True +>>> converter = lambda s: datetime.datetime.strptime(s, '%Y/%m/%d') +>>> P.from_string('[2011/03/15, 2013/10/10]', conv=converter) +[datetime.datetime(2011, 3, 15, 0, 0),datetime.datetime(2013, 10, 10, 0, 0)] + +``` + +Similarly to `to_string`, function `from_string` can be parametrized to deal with more elaborated inputs. +Notice that as `from_string` expects regular expression patterns, we need to escape some characters. + +```python +>>> s = '.."0" - "1"> or <"2" - +oo..' +>>> params = { +... 'disj': ' or ', +... 'sep': ' - ', +... 'left_closed': '<', +... 'right_closed': '>', +... 'left_open': r'\.\.', # from_string expects regular expression patterns +... 'right_open': r'\.\.', # from_string expects regular expression patterns +... 'pinf': r'\+oo', # from_string expects regular expression patterns +... 'ninf': '-oo', +... 'conv': lambda v: int(v[1:-1]), +... } +>>> P.from_string(s, **params) +(0,1] | [2,+inf) + +``` + +When a bound contains a comma or has a representation that cannot be automatically parsed with `from_string`, +the `bound` parameter can be used to specify the regular expression that should be used to match its representation. + +```python +>>> s = '[(0, 1), (2, 3)]' # Bounds are expected to be tuples +>>> P.from_string(s, conv=eval, bound=r'\(.+?\)') +[(0, 1),(2, 3)] + +``` + + +[↑ back to top](#table-of-contents) +### Import & export intervals to Python built-in data types + +Intervals can also be exported to a list of 4-uples with `to_data`, e.g., to support JSON serialization. +`P.CLOSED` and `P.OPEN` are represented by Boolean values `True` (inclusive) and `False` (exclusive). + +```python +>>> P.to_data(P.openclosed(0, 2)) +[(False, 0, 2, True)] + +``` + +The values used to represent positive and negative infinities can be specified with +`pinf` and `ninf`. They default to `float('inf')` and `float('-inf')` respectively. + +```python +>>> x = P.openclosed(0, 1) | P.closedopen(2, P.inf) +>>> P.to_data(x) +[(False, 0, 1, True), (True, 2, inf, False)] + +``` + +The function to convert bounds can be specified with the `conv` parameter. + +```python +>>> x = P.closedopen(datetime.date(2011, 3, 15), datetime.date(2013, 10, 10)) +>>> P.to_data(x, conv=lambda v: (v.year, v.month, v.day)) +[(True, (2011, 3, 15), (2013, 10, 10), False)] + +``` + +Intervals can be imported from such a list of 4-tuples with `from_data`. +The same set of parameters can be used to specify how bounds and infinities are converted. + +```python +>>> x = [(True, (2011, 3, 15), (2013, 10, 10), False)] +>>> P.from_data(x, conv=lambda v: datetime.date(*v)) +[datetime.date(2011, 3, 15),datetime.date(2013, 10, 10)) + +``` + + +[↑ back to top](#table-of-contents) +### Specialize & customize intervals + +**Disclaimer**: the features explained in this section are still experimental and are subject to backward incompatible changes even in minor or patch updates of `portion`. + +The intervals provided by `portion` already cover a wide range of use cases. +However, in some situations, it might be interesting to specialize or customize these intervals. +One typical example would be to support discrete intervals such as intervals of integers. + +While it is definitely possible to rely on the default intervals provided by `portion` to encode discrete +intervals, there are a few edge cases that lead some operations to return unexpected results: + +```python +>>> P.singleton(0) | P.singleton(1) # Case 1: should be [0,1] for discrete numbers +[0] | [1] +>>> P.open(0, 1) # Case 2: should be empty +(0,1) +>>> P.closedopen(0, 1) # Case 3: should be singleton [0] +[0,1) + +``` + +The `portion` library makes its best to ease defining and using subclasses of `Interval` to address +these situations. In particular, `Interval` instances always produce new intervals using `self.__class__`, and the class is written in a way that most of its methods can be easily extended. + +To implement a class for intervals of discrete numbers and to cover the three aforementioned cases, we need to change the behaviour of the `Interval._mergeable` class method (to address first case) and of the `Interval.from_atomic` class method (for cases 2 and 3). +The former is used to detect whether two atomic intervals can be merged into a single interval, while the latter is used to create atomic intervals. + +Thankfully, since discrete intervals are expected to be a frequent use case, `portion` provides an `AbstractDiscreteInterval` class that already makes the appropriate changes to these two methods. +As indicated by its name, this class cannot be used directly and should be inherited. +In particular, one has either to provide a `_step` class attribute to define the step between consecutive discrete values, or to define the `_incr` and `_decr` class methods: + +```python +>>> class IntInterval(P.AbstractDiscreteInterval): +... _step = 1 + +``` + +That's all! +We can now use this class to manipulate intervals of discrete numbers and see it covers the three problematic cases: + +```python +>>> IntInterval.from_atomic(P.CLOSED, 0, 0, P.CLOSED) | IntInterval.from_atomic(P.CLOSED, 1, 1, P.CLOSED) +[0,1] +>>> IntInterval.from_atomic(P.OPEN, 0, 1, P.OPEN) +() +>>> IntInterval.from_atomic(P.CLOSED, 0, 1, P.OPEN) +[0] + +``` + +As an example of using `_incr` and `_decr`, consider the following `CharInterval` subclass tailored to manipulate intervals of characters: + +```python +>>> class CharInterval(P.AbstractDiscreteInterval): +... _incr = lambda v: chr(ord(v) + 1) +... _decr = lambda v: chr(ord(v) - 1) +>>> CharInterval.from_atomic(P.OPEN, 'a', 'z', P.OPEN) +['b','y'] + +``` + +Having to call `from_atomic` on the subclass to create intervals is quite verbose. +For convenience, all the functions that create interval instances accept an additional `klass` parameter to specify the class that creates intervals, circumventing the direct use of the class constructors. +However, having to specify the `klass` parameter in each call to `P.closed` or other helpers that create intervals is still a bit too verbose to be convenient. +Consequently, `portion` provides a `create_api` function that, given a subclass of `Interval`, returns a dynamically generated module whose API is similar to the one of `portion` but configured to use the subclass instead: + +```python +>>> D = P.create_api(IntInterval) +>>> D.singleton(0) | D.singleton(1) +[0,1] +>>> D.open(0, 1) +() +>>> D.closedopen(0, 1) +[0] + +``` + +This makes it easy to use our newly defined `IntInterval` subclass while still benefiting from `portion`'s API. + +Let's extend our example to support intervals of natural numbers. +Such intervals are quite similar to the above ones, except they cannot go over negative values. +We can prevent the bounds of an interval to be negative by slightly changing the `from_atomic` class method as follows: + +```python +>>> class NaturalInterval(IntInterval): +... @classmethod +... def from_atomic(cls, left, lower, upper, right): +... return super().from_atomic( +... P.CLOSED if lower < 0 else left, +... max(0, lower), +... upper, +... right, +... ) + +``` + +We can now define and use the `N` module to check whether our newly defined `NaturalInterval` does the job: + +```python +>>> N = P.create_api(NaturalInterval) +>>> N.closed(-10, 2) +[0,2] +>>> N.open(-10, 2) +[0,1] +>>> ~N.empty() +[0,+inf) + +``` + +Keep in mind that just because `NaturalInterval` has semantics associated with natural numbers does not mean that all possible operations on these intervals strictly comply the semantics. +The following examples illustrate some of the cases where additional checks should be implemented to strictly adhere to these semantics: + +```python +>>> N.closed(1.5, 2.5) # Bounds are not natural numbers +[1.5,2.5] +>>> 0.5 in N.closed(0, 1) # Given value is not a natural number +True +>>> ~N.singleton(0.5) +[1.5,+inf) + +``` + + + +[↑ back to top](#table-of-contents) +## Changelog + +This library adheres to a [semantic versioning](https://semver.org) scheme. +See [CHANGELOG.md](https://github.com/AlexandreDecan/portion/blob/master/CHANGELOG.md) for the list of changes. + + + +## Contributions + +Contributions are very welcome! +Feel free to report bugs or suggest new features using GitHub issues and/or pull requests. + + + +## License + +Distributed under [LGPLv3 - GNU Lesser General Public License, version 3](https://github.com/AlexandreDecan/portion/blob/master/LICENSE.txt). + +You can refer to this library using: + +``` +@software{portion, + author = {Decan, Alexandre}, + title = {portion: Python data structure and operations for intervals}, + url = {https://github.com/AlexandreDecan/portion}, +} +``` + + + + +%prep +%autosetup -n portion-2.4.0 + +%build +%py3_build + +%install +%py3_install +install -d -m755 %{buildroot}/%{_pkgdocdir} +if [ -d doc ]; then cp -arf doc %{buildroot}/%{_pkgdocdir}; fi +if [ -d docs ]; then cp -arf docs %{buildroot}/%{_pkgdocdir}; fi +if [ -d example ]; then cp -arf example %{buildroot}/%{_pkgdocdir}; fi +if [ -d examples ]; then cp -arf examples %{buildroot}/%{_pkgdocdir}; fi +pushd %{buildroot} +if [ -d usr/lib ]; then + find usr/lib -type f -printf "/%h/%f\n" >> filelist.lst +fi +if [ -d usr/lib64 ]; then + find usr/lib64 -type f -printf "/%h/%f\n" >> filelist.lst +fi +if [ -d usr/bin ]; then + find usr/bin -type f -printf "/%h/%f\n" >> filelist.lst +fi +if [ -d usr/sbin ]; then + find usr/sbin -type f -printf "/%h/%f\n" >> filelist.lst +fi +touch doclist.lst +if [ -d usr/share/man ]; then + find usr/share/man -type f -printf "/%h/%f.gz\n" >> doclist.lst +fi +popd +mv %{buildroot}/filelist.lst . +mv %{buildroot}/doclist.lst . + +%files -n python3-portion -f filelist.lst +%dir %{python3_sitelib}/* + +%files help -f doclist.lst +%{_docdir}/* + +%changelog +* Tue Apr 11 2023 Python_Bot - 2.4.0-1 +- Package Spec generated -- cgit v1.2.3