├── doc ├── recipes.rst ├── conftest.py ├── example_contains.inc.rst ├── introduction.rst ├── conf.py ├── index.rst ├── api.rst ├── custom_types.rst ├── recipe_overlap.inc.rst ├── ranges.rst ├── postgresql.rst └── changelog.rst ├── tests ├── test_intrange.py ├── test_offsetablerange.py ├── test_strrange.py ├── test_discreterange.py ├── test_utils.py ├── test_floatrange.py ├── test_periodrange.py ├── test_daterange.py ├── test_rangeset.py └── test_range.py ├── .gitignore ├── .git-blame-ignore-revs ├── spans ├── __init__.py ├── _utils.py ├── settypes.py └── types.py ├── LICENSE ├── pyproject.toml ├── benchmark.py ├── README.rst ├── .github └── workflows │ └── ci.yml └── poetry.lock /doc/recipes.rst: -------------------------------------------------------------------------------- 1 | Recipes 2 | ======= 3 | This is a showcasing of what Spans is capable of. 4 | 5 | .. include:: recipe_overlap.inc.rst 6 | -------------------------------------------------------------------------------- /tests/test_intrange.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spans import intrange 4 | 5 | 6 | def test_len(): 7 | assert len(intrange(0, 5)) == 5 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.egg 4 | *.egg-info/ 5 | 6 | .coverage 7 | 8 | .cache/ 9 | build/ 10 | dist/ 11 | *venv/ 12 | doc/_build/ 13 | 14 | .vscode/ 15 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Black auto-formatter added 2 | 11f381b7b3928e612e02aa17bdcb0d3afa898db8 3 | 4 | # isort auto import formatter 5 | ed3bf1afb775b735eeeb5eb86819711f4d14be0c 6 | -------------------------------------------------------------------------------- /doc/conftest.py: -------------------------------------------------------------------------------- 1 | import spans 2 | import pytest 3 | 4 | from datetime import date, datetime, timedelta 5 | 6 | @pytest.fixture(autouse=True) 7 | def doctest_fixture(doctest_namespace): 8 | for attr in spans.__all__: 9 | doctest_namespace[attr] = getattr(spans, attr) 10 | 11 | doctest_namespace["date"] = date 12 | doctest_namespace["datetime"] = datetime 13 | doctest_namespace["timedelta"] = timedelta 14 | -------------------------------------------------------------------------------- /tests/test_offsetablerange.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spans import intrange 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "a, offset, b", 8 | [ 9 | (intrange(0), 5, intrange(5)), 10 | (intrange(upper=0), 5, intrange(upper=5)), 11 | (intrange(0, 5), 5, intrange(5, 10)), 12 | (intrange(), 5, intrange()), 13 | ], 14 | ) 15 | def test_offset(a, offset, b): 16 | assert a.offset(offset) == b 17 | -------------------------------------------------------------------------------- /doc/example_contains.inc.rst: -------------------------------------------------------------------------------- 1 | .. doctest:: 2 | 3 | >>> from spans import daterange 4 | >>> from datetime import date 5 | >>> the90s = daterange(date(1990, 1, 1), date(2000, 1, 1)) 6 | >>> date(1996, 12, 4) in the90s 7 | True 8 | >>> date(2000, 1, 1) in the90s 9 | False 10 | >>> the90s.union(daterange(date(2000, 1, 1), date(2010, 1, 1))) 11 | daterange(datetime.date(1990, 1, 1), datetime.date(2010, 1, 1)) 12 | -------------------------------------------------------------------------------- /tests/test_strrange.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from spans import strrange 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "span, last", 10 | [ 11 | (strrange("a", "c"), "b"), 12 | (strrange("aa", "cc"), "cb"), 13 | ], 14 | ) 15 | def test_last(span, last): 16 | assert span.last == last 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "a, b", 21 | [ 22 | ("", ""), 23 | ("b", "a"), 24 | (chr(0), chr(sys.maxunicode)), 25 | ], 26 | ) 27 | def test_prev(a, b): 28 | assert strrange.prev(a) == b 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "a, b", 33 | [ 34 | ("", ""), 35 | ("a", "b"), 36 | (chr(sys.maxunicode), chr(0)), 37 | ], 38 | ) 39 | def test_next(a, b): 40 | assert strrange.next(a) == b 41 | -------------------------------------------------------------------------------- /tests/test_discreterange.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | import pytest 4 | 5 | from spans import daterange, intrange 6 | 7 | 8 | def test_last(): 9 | assert intrange(1).last is None 10 | 11 | 12 | def test_iter(): 13 | assert list(intrange(0, 5)) == list(range(5)) 14 | 15 | infinite_iter = iter(intrange(0)) 16 | for i in range(100): 17 | assert i == next(infinite_iter) 18 | 19 | 20 | def test_no_lower_bound_iter(): 21 | with pytest.raises(TypeError): 22 | next(iter(intrange(upper=1))) 23 | 24 | 25 | def test_reversed(): 26 | assert list(reversed(intrange(0, 5))) == list(reversed(range(5))) 27 | 28 | infinite_iter = reversed(intrange(upper=100)) 29 | for i in reversed(range(100)): 30 | assert i == next(infinite_iter) 31 | 32 | 33 | def test_no_lower_upper_reversed(): 34 | with pytest.raises(TypeError): 35 | next(reversed(intrange(1))) 36 | -------------------------------------------------------------------------------- /spans/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides one dimensional continuous set support for python. 3 | 4 | Python has a wonderful set class, it does however not handle continuous sets 5 | between two endpoints. This module tries to mitigate that. The ranges' behavoir 6 | are modeled after PostgresSQL 9.2's range types. Deviating from PostgresSQL's 7 | behavoir is considered a bug. 8 | 9 | In addition to the range types there are range sets. A range set is can be viewed 10 | as a mutable list of ranges. A set enables discontinious chunks to be grouped 11 | together. 12 | """ 13 | 14 | 15 | __version__ = "1.1.1" 16 | 17 | 18 | __all__ = [ 19 | "intrange", 20 | "floatrange", 21 | "strrange", 22 | "daterange", 23 | "datetimerange", 24 | "timedeltarange", 25 | "PeriodRange", 26 | "intrangeset", 27 | "floatrangeset", 28 | "strrangeset", 29 | "daterangeset", 30 | "datetimerangeset", 31 | "timedeltarangeset", 32 | ] 33 | 34 | 35 | from .settypes import * 36 | from .types import * 37 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | 5 | from spans._utils import date_from_iso_week, find_slots 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "args, day", 10 | [ 11 | ((1999, 52), date(1999, 12, 27)), 12 | ((2000, 1), date(2000, 1, 3)), 13 | ((2000, 2), date(2000, 1, 10)), 14 | ((2009, 53), date(2009, 12, 28)), 15 | ((2010, 1), date(2010, 1, 4)), 16 | ], 17 | ) 18 | def test_date_from_iso_week(args, day): 19 | assert date_from_iso_week(*args) == day 20 | 21 | 22 | @pytest.mark.parametrize("day", [0, 8]) 23 | def test_date_from_iso_week_invalid_day_of_week(day): 24 | with pytest.raises(ValueError): 25 | date_from_iso_week(2000, 1, day_of_week=day) 26 | 27 | 28 | def test_find_slots_single(): 29 | class Slots(object): 30 | __slots__ = "single" 31 | 32 | assert find_slots(Slots) == {"single"} 33 | 34 | 35 | def test_find_slots_multiple(): 36 | class Slots(object): 37 | __slots__ = ("a", "b") 38 | 39 | assert find_slots(Slots) == {"a", "b"} 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Andreas Runfalk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "spans" 3 | version = "2.0.0" 4 | description = "Continuous set support for Python" 5 | repository = "https://github.com/runfalk/spans/" 6 | documentation = "https://runfalk.github.io/spans/" 7 | readme = "README.rst" 8 | authors = ["Andreas Runfalk "] 9 | license = "MIT" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: Implementation :: CPython", 21 | "Programming Language :: Python :: Implementation :: PyPy", 22 | "Topic :: Utilities" 23 | ] 24 | include = [ 25 | "doc", 26 | "tests", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.7" # The oldest supported Python version 31 | 32 | [tool.poetry.dev-dependencies] 33 | pytest = "^7.1.2" 34 | Sphinx = "^5.0.2" 35 | black = "^22.3.0" 36 | isort = "^5.10.1" 37 | 38 | [build-system] 39 | requires = ["poetry-core>=1.0.0"] 40 | build-backend = "poetry.core.masonry.api" 41 | 42 | [tool.pytest.ini_options] 43 | addopts = "--doctest-glob='*.rst' --doctest-modules" 44 | norecursedirs = "_build" 45 | testpaths = "spans tests doc README.rst" 46 | 47 | [tool.isort] 48 | profile = "black" 49 | -------------------------------------------------------------------------------- /benchmark.py: -------------------------------------------------------------------------------- 1 | from timeit import timeit, repeat 2 | 3 | from spans import * 4 | 5 | 6 | def format_sec(s): 7 | """ 8 | Format seconds in a more human readable way. It supports units down to 9 | nanoseconds. 10 | 11 | :param s: Float of seconds to format 12 | :return: String second representation, like 12.4 us 13 | """ 14 | 15 | prefixes = ["", "m", "u", "n"] 16 | unit = 0 17 | 18 | while s < 1 and unit + 1 < len(prefixes): 19 | s *= 1000 20 | unit += 1 21 | 22 | return "{:.1f} {}s".format(s, prefixes[unit]) 23 | 24 | 25 | def run_benchmark(func, number=None): 26 | if number is None: 27 | number = 1000 28 | 29 | total_time = sum(repeat(func, repeat=3, number=number)) / 3 30 | 31 | print("{func:.<40} {loops} loops, best of 3: {per_loop} per loop".format( 32 | func=func.__name__ + " ", 33 | loops=number, 34 | per_loop=format_sec(total_time / float(number)))) 35 | 36 | 37 | # Create ranges here to prevent __init__ from affecting test results 38 | a = intrange(1, 5) 39 | b = intrange(5, 10) 40 | c = intrange(10, 15) 41 | 42 | ab = a.union(b) 43 | bc = b.union(c) 44 | 45 | abc = ab.union(c) 46 | 47 | 48 | def test_union(): 49 | a.union(b) 50 | ab.union(bc) 51 | 52 | 53 | def test_intersection(): 54 | a.intersection(b) 55 | ab.intersection(bc) 56 | 57 | 58 | def test_difference(): 59 | a.difference(b) 60 | ab.difference(bc) 61 | 62 | 63 | def test_overlap(): 64 | a.overlap(b) 65 | ab.overlap(bc) 66 | 67 | 68 | def test_left_of(): 69 | a.left_of(bc) 70 | b.left_of(a) 71 | 72 | 73 | tests = [func for name, func in locals().items() if name.startswith("test_")] 74 | for func in sorted(tests, key=lambda v: v.__name__): 75 | run_benchmark(func, number=10000) 76 | -------------------------------------------------------------------------------- /doc/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | For a recent project of mine I started using PostgreSQL's ``tsrange`` type and needed an equivalent in Python. These range types attempt to mimick PostgreSQL's behavior in every way. To make ranges more useful some extra methods have been added that are not available in PostgreSQL. 4 | 5 | Requirements 6 | ------------ 7 | Spans have no requirements but the standard library. It is known to work on the following Python versions 8 | 9 | - Python 3.7 10 | - Python 3.8 11 | - Python 3.9 12 | - Python 3.10 13 | - PyPy3 14 | 15 | It may work on other version as well. 16 | 17 | Installation 18 | ------------ 19 | Spans is available from `PyPI `_. 20 | 21 | .. code-block:: bash 22 | 23 | $ pip install spans 24 | 25 | 26 | Example 27 | ------- 28 | If you are making a booking application for a bed and breakfast hotel and want 29 | to ensure no room gets double booked: 30 | 31 | .. code-block:: python 32 | :linenos: 33 | 34 | from collections import defaultdict 35 | from datetime import date 36 | from spans import daterange 37 | 38 | # Add a booking from 2013-01-14 through 2013-01-15 39 | bookings = defaultdict(list, { 40 | 1 : [daterange(date(2013, 1, 14), date(2013, 1, 16))] 41 | } 42 | 43 | def is_valid_booking(bookings, room, new_booking): 44 | return not any(booking.overlap(new_booking for booking in bookings[room]) 45 | 46 | print is_valid_booking( 47 | bookings, 1, daterange(date(2013, 1, 14), date(2013, 1, 18))) # False 48 | print is_valid_booking( 49 | bookings, 1, daterange(date(2013, 1, 16), date(2013, 1, 18))) # True 50 | 51 | 52 | Using with Psycopg 53 | ------------------ 54 | To use Spans with `Psycopg `_ the `Psycospans `_ project exists. 55 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Spans' 21 | copyright = '2022, Andreas Runfalk' 22 | author = 'Andreas Runfalk' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.doctest", 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = 'alabaster' 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ['_static'] 55 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Spans |release| documentation 2 | ============================= 3 | Spans is a pure Python implementation of PostgreSQL's 4 | `range types `_. 5 | Range types are conveinent when working with intervals of any kind. Every time 6 | you've found yourself working with date_start and date_end, an interval may have 7 | been what you were actually looking for. 8 | 9 | Spans has successfully been used in production since its first release 10 | 30th August, 2013. 11 | 12 | 13 | Example 14 | ------- 15 | Imagine you are building a calendar and want to display all weeks that overlaps 16 | the current month. Normally you have to do some date trickery to achieve this, 17 | since the month's bounds may be any day of the week. With Spans' set-like 18 | operations and shortcuts the problem becomes a breeze. 19 | 20 | We start by importing ``date`` and ``daterange`` 21 | 22 | .. code-block:: python 23 | 24 | >>> from datetime import date 25 | >>> from spans import daterange 26 | 27 | Using ``daterange.from_month`` we can get range representing January in the year 28 | 2000 29 | 30 | .. code-block:: python 31 | 32 | >>> month = daterange.from_month(2000, 1) 33 | >>> month 34 | daterange(datetime.date(2000, 1, 1), datetime.date(2000, 2, 1)) 35 | 36 | Now we can calculate the ranges for the weeks where the first and last day of 37 | month are 38 | 39 | .. code-block:: python 40 | 41 | >>> start_week = daterange.from_date(month.lower, period="week") 42 | >>> end_week = daterange.from_date(month.last, period="week") 43 | >>> start_week 44 | daterange(datetime.date(1999, 12, 27), datetime.date(2000, 1, 3)) 45 | >>> end_week 46 | daterange(datetime.date(2000, 1, 31), datetime.date(2000, 2, 7)) 47 | 48 | Using a union we can express the calendar view. 49 | 50 | .. code-block:: python 51 | 52 | >>> start_week.union(month).union(end_week) 53 | daterange(datetime.date(1999, 12, 27), datetime.date(2000, 2, 7)) 54 | 55 | 56 | Introduction 57 | ------------ 58 | 59 | .. toctree:: 60 | :maxdepth: 2 61 | 62 | self 63 | introduction 64 | ranges 65 | custom_types 66 | recipes 67 | postgresql 68 | api 69 | changelog 70 | -------------------------------------------------------------------------------- /tests/test_floatrange.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from spans import floatrange 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "lower, upper", 8 | [ 9 | ("foo", None), 10 | (None, "foo"), 11 | ], 12 | ) 13 | def test_type_check(lower, upper): 14 | with pytest.raises(TypeError): 15 | floatrange(lower, upper) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "lower, upper", 20 | [ 21 | (10.0, 5.0), 22 | ], 23 | ) 24 | def test_invalid_bounds(lower, upper): 25 | with pytest.raises(ValueError): 26 | floatrange(lower, upper) 27 | 28 | 29 | def test_less_than(): 30 | # Special case that discrete ranges can't cover 31 | assert floatrange(1.0) < floatrange(1.0, lower_inc=False) 32 | assert not floatrange(1.0, lower_inc=False) < floatrange(1.0) 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "span, value", 37 | [ 38 | (floatrange(1.0, 5.0), 1.0), 39 | (floatrange(1.0, 5.0), 3.0), 40 | (floatrange(1.0, 5.0, upper_inc=True), 5.0), 41 | (floatrange(1.0, 5.0, lower_inc=False, upper_inc=True), 5.0), 42 | ], 43 | ) 44 | def test_contains(span, value): 45 | assert span.contains(value) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "span, value", 50 | [ 51 | (floatrange(1.0, 5.0, lower_inc=False), 1.0), 52 | (floatrange(1.0, 5.0), 5.0), 53 | ], 54 | ) 55 | def test_not_contains(span, value): 56 | assert not span.contains(value) 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "a, b", 61 | [ 62 | (floatrange(1.0, lower_inc=False), 1.0), 63 | (floatrange(1.0, lower_inc=False), floatrange(1.0)), 64 | ], 65 | ) 66 | def test_startswith(a, b): 67 | # Special case that discrete ranges can't cover 68 | assert not a.startswith(b) 69 | 70 | 71 | def test_endswith(): 72 | # Special case that discrete ranges can't cover 73 | assert floatrange(upper=5.0, upper_inc=True).endswith(5.0) 74 | 75 | 76 | @pytest.mark.parametrize( 77 | "a, b", 78 | [ 79 | (floatrange(upper=5.0), 5.0), 80 | (floatrange(upper=5.0), floatrange(upper=5.0, upper_inc=True)), 81 | ], 82 | ) 83 | def test_not_endswith(a, b): 84 | # Special case that discrete ranges can't cover 85 | assert not a.endswith(b) 86 | -------------------------------------------------------------------------------- /spans/_utils.py: -------------------------------------------------------------------------------- 1 | """Helper functions""" 2 | 3 | from datetime import date, datetime, timedelta 4 | 5 | __all__ = [ 6 | "date_from_iso_week", 7 | "find_slots", 8 | "PicklableSlotMixin", 9 | ] 10 | 11 | 12 | def date_from_iso_week(year, week, day_of_week=None): 13 | if day_of_week is None: 14 | day_of_week = 1 15 | 16 | if not 1 <= day_of_week <= 7: 17 | raise ValueError( 18 | f"Day of week is not in range 1 through 7, got {day_of_week!r}" 19 | ) 20 | 21 | day = datetime.strptime(f"{year:04d}-{week:02d}-{day_of_week:d}", "%Y-%W-%w") 22 | 23 | # ISO week 1 is defined as the first week to have 4 or more days in January. 24 | # Python's built-in date parsing considers the week that contain the first 25 | # Monday of the year to be the first week. 26 | if date(year, 1, 4).isoweekday() > 4: 27 | day -= timedelta(days=7) 28 | 29 | return day.date() 30 | 31 | 32 | def find_slots(cls): 33 | """Return a set of all slots for a given class and its parents""" 34 | 35 | slots = set() 36 | for c in cls.__mro__: 37 | cslots = getattr(c, "__slots__", tuple()) 38 | 39 | if not cslots: 40 | continue 41 | elif isinstance(cslots, str): 42 | cslots = (cslots,) 43 | 44 | slots.update(cslots) 45 | 46 | return slots 47 | 48 | 49 | class PicklableSlotMixin(object): 50 | __slots__ = () 51 | 52 | def __getstate__(self): 53 | return {attr: getattr(self, attr) for attr in find_slots(self.__class__)} 54 | 55 | def __setstate__(self, data): 56 | for attr, value in data.items(): 57 | setattr(self, attr, value) 58 | 59 | 60 | class PartialOrderingMixin(object): 61 | __slots__ = () 62 | 63 | def __le__(self, other): 64 | lt = self.__lt__(other) 65 | eq = self.__eq__(other) 66 | 67 | if lt is NotImplemented and eq is NotImplemented: 68 | return NotImplemented 69 | return lt is True or eq is True 70 | 71 | def __gt__(self, other): 72 | le = self.__le__(other) 73 | if le is NotImplemented: 74 | return NotImplemented 75 | return not le 76 | 77 | def __ge__(self, other): 78 | gt = self.__gt__(other) 79 | eq = self.__eq__(other) 80 | if gt is NotImplemented and eq is NotImplemented: 81 | return NotImplemented 82 | return gt is True or eq is True 83 | 84 | def __ne__(self, other): 85 | eq = self.__eq__(other) 86 | if eq is NotImplemented: 87 | return NotImplemented 88 | 89 | return not eq 90 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | .. module:: spans 2 | 3 | API documentation 4 | ================= 5 | This is the API reference for all public classes and functions in Spans. 6 | 7 | 8 | Ranges 9 | ------ 10 | 11 | Range class 12 | ~~~~~~~~~~~ 13 | .. autoclass:: spans.types.Range 14 | :members: 15 | 16 | 17 | Discrete range 18 | ~~~~~~~~~~~~~~ 19 | .. autoclass:: spans.types.DiscreteRange 20 | :members: 21 | 22 | 23 | Offsetable range mixin 24 | ~~~~~~~~~~~~~~~~~~~~~~ 25 | .. autoclass:: spans.types.OffsetableRangeMixin 26 | :members: offset 27 | 28 | 29 | Integer range 30 | ~~~~~~~~~~~~~ 31 | .. autoclass:: spans.types.intrange 32 | 33 | 34 | Float range 35 | ~~~~~~~~~~~ 36 | .. autoclass:: spans.types.floatrange 37 | 38 | 39 | String range 40 | ~~~~~~~~~~~~ 41 | .. autoclass:: spans.types.strrange 42 | 43 | 44 | Date range 45 | ~~~~~~~~~~ 46 | .. autoclass:: spans.types.daterange 47 | :members: 48 | :special-members: __len__ 49 | 50 | 51 | Period range 52 | ~~~~~~~~~~~~ 53 | .. autoclass:: spans.types.PeriodRange 54 | :members: 55 | 56 | 57 | Datetime range 58 | ~~~~~~~~~~~~~~ 59 | .. autoclass:: spans.types.datetimerange 60 | 61 | 62 | Timedelta range 63 | ~~~~~~~~~~~~~~~ 64 | .. autoclass:: spans.types.timedeltarange 65 | 66 | 67 | Range sets 68 | ---------- 69 | 70 | Range set 71 | ~~~~~~~~~ 72 | .. autoclass:: spans.settypes.RangeSet 73 | :members: 74 | :special-members: __iter__, __len__ 75 | 76 | 77 | Discrete range set mixin 78 | ~~~~~~~~~~~~~~~~~~~~~~~~ 79 | .. autoclass:: spans.settypes.DiscreteRangeSetMixin 80 | :members: values 81 | 82 | 83 | Offsetable range set mixin 84 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 85 | .. autoclass:: spans.settypes.OffsetableRangeSetMixin 86 | :members: offset 87 | 88 | 89 | Integer range set 90 | ~~~~~~~~~~~~~~~~~ 91 | .. autoclass:: spans.settypes.intrangeset 92 | 93 | 94 | Float range set 95 | ~~~~~~~~~~~~~~~ 96 | .. autoclass:: spans.settypes.floatrangeset 97 | 98 | 99 | String range set 100 | ~~~~~~~~~~~~~~~~ 101 | .. autoclass:: spans.settypes.strrangeset 102 | 103 | 104 | Date range set 105 | ~~~~~~~~~~~~~~ 106 | .. autoclass:: spans.settypes.daterangeset 107 | 108 | 109 | Datetime range set 110 | ~~~~~~~~~~~~~~~~~~ 111 | .. autoclass:: spans.settypes.datetimerangeset 112 | 113 | 114 | Timedelta range set 115 | ~~~~~~~~~~~~~~~~~~~ 116 | .. autoclass:: spans.settypes.timedeltarangeset 117 | 118 | 119 | Meta range set 120 | ~~~~~~~~~~~~~~ 121 | .. autoclass:: spans.settypes.MetaRangeSet 122 | 123 | 124 | 125 | Legacy names 126 | ------------ 127 | Historically some internal Spans classes had all lowercase names. This was changed in version 0.5.0. The reason some classes still have lowercase names is to match the Python built-ins they map to. ``date``'s range type is and will always be :class:`~spans.types.daterange`. However, it doesn't make much sense to maintain this convention for the more hidden classes in Spans. 128 | 129 | .. automodule:: spans.types 130 | :members: range_, discreterange, offsetablerange 131 | 132 | .. automodule:: spans.settypes 133 | :members: metarangeset, rangeset 134 | -------------------------------------------------------------------------------- /doc/custom_types.rst: -------------------------------------------------------------------------------- 1 | Custom range types 2 | ================== 3 | The built in range types may not suffice for your particular application. It is very easy to extend with your own classes. The only requirement is that the type supports rich comparison :pep:`207` and is immutable. 4 | 5 | 6 | Standard range types 7 | -------------------- 8 | A normal range can be implemented by extending :class:`spans.types.Range`. 9 | 10 | .. code-block:: python 11 | 12 | from spans.types import Range 13 | 14 | class floatrange(Range): 15 | __slots__ = () 16 | type = float 17 | 18 | span = floatrange(1.0, 5.0) 19 | 20 | assert span.lower == 1.0 21 | assert span.upper == 5.0 22 | 23 | .. note:: 24 | The ``__slots__ = ()`` is a performance optimization that is used for all ranges. It lowers the memory footprint for every instance. It is not mandatory but encourgaged. 25 | 26 | 27 | Offsetable range types 28 | ---------------------- 29 | An offsetable range can be implemented using the mixin :class:`spans.types.OffsetableRangeMixin`. The class still needs to extend :class:`spans.types.Range`. 30 | 31 | .. code-block:: python 32 | 33 | from spans.types import Range, OffsetableRangeMixin 34 | 35 | class floatrange(Range, OffsetableRangeMixin): 36 | __slots__ = () 37 | type = float 38 | 39 | If the offset type is not the same as the range type (such as ``date`` that is offsetable with ``timedelta``) the attribute ``offset_type`` can be used. 40 | 41 | .. code-block:: python 42 | 43 | from spans.types import DiscreteRange, OffsetableRangeMixin 44 | from datetime import date, timedelta 45 | 46 | class daterange(DiscreteRange, OffsetableRangeMixin): 47 | __slots__ = () 48 | 49 | type = date 50 | offset_type = timedelta 51 | 52 | span = daterange(date(2000, 1, 1), date(2000, 2, 1)) 53 | assert span.offset(timedelta(14)).upper == date(2000, 2, 15) 54 | 55 | 56 | Discrete range types 57 | -------------------- 58 | Discrete ranges (such as :class:`~spans.types.intrange` and :class:`~spans.types.daterange`) can be implemented by extending :class:`spans.types.DiscreteRange`. 59 | 60 | .. code-block:: python 61 | 62 | from spans.types import DiscreteRange, OffsetableRangeMixin 63 | 64 | class intrange(DiscreteRange, OffsetableRangeMixin): 65 | __slots__ = () 66 | type = intrange 67 | step = 1 68 | 69 | assert list(intrange(1, 5)) == [1, 2, 3, 4] 70 | 71 | Note the ``step`` attribute. It must always be the smallest possible unit. Using ``2`` for intranges would not have expected behavior. 72 | 73 | 74 | Range sets 75 | ---------- 76 | Range sets are conveinient to implement regardless of the mixins used. This is due to the metaclass :class:`spans.settypes.MetaRangeSet`. The metaclass automatically adds required mixins to the range set type. 77 | 78 | .. code-block:: python 79 | 80 | from spans.types import intrange 81 | from spans.settypes import RangeSet 82 | 83 | class intrangeset(RangeSet): 84 | __slots__ = () 85 | type = intrange 86 | 87 | assert intrangeset( 88 | [intrange(1, 5), intrange(10, 15)]).span() == intrange(1, 15) 89 | 90 | 91 | Custom mixins 92 | ------------- 93 | It is possible to create custom mixins for range sets by adding mappings to :class:`spans.settypes.MetaRangeSet`. The mapping has to be added before the range set class is created or it will not be used. 94 | -------------------------------------------------------------------------------- /doc/recipe_overlap.inc.rst: -------------------------------------------------------------------------------- 1 | Check if two employees work at the same time 2 | -------------------------------------------- 3 | Spans make working with intervals of time easy. In this example we want to list all hours where `Alice` and `Bob` work at the same time. 24 hour clock is used, as well as weeks starting on Monday. 4 | 5 | .. testcode:: 6 | 7 | import re 8 | from datetime import timedelta 9 | from spans import timedeltarange, timedeltarangeset 10 | 11 | 12 | def str_to_timedeltarange(string): 13 | """ 14 | Convert a string from the format (HH:MM-HH:MM) into a ``timedeltarange`` 15 | 16 | :param string: String time representation in the format HH:MM. Minutes and 17 | the leading zero of the hours may be omitted. 18 | :return: A new ``timedeltarange`` instance 19 | """ 20 | 21 | # NOTE: Error handling left as an exercise for the reader 22 | match = re.match( 23 | "^(\d{1,2}):?(\d{1,2})?-(\d{1,2}):?(\d{1,2})?$", string) 24 | 25 | start_hour, start_min = (int(v or 0) for v in match.group(1, 2)) 26 | end_hour, end_min = (int(v or 0) for v in match.group(3, 4)) 27 | 28 | return timedeltarange( 29 | timedelta(hours=start_hour, minutes=start_min), 30 | timedelta(hours=end_hour, minutes=end_min)) 31 | 32 | 33 | def timedeltarange_to_str(span): 34 | """ 35 | Convert a ``timedeltarange`` to a string representation (HH:MM-HH:MM). 36 | 37 | :param span: ``timedeltarange`` to convert 38 | :return: String representation 39 | """ 40 | 41 | return "{:02}:{:02}-{:02}:{:02}".format( 42 | span.lower.seconds // 3600, 43 | (span.lower.seconds // 60) % 60, 44 | span.upper.seconds // 3600, 45 | (span.upper.seconds // 60) % 60 46 | ) 47 | 48 | 49 | hours_alice = [ 50 | ["8-12", "12:30-17"], # Monday 51 | ["8-12", "12:30-17"], # Tuesday 52 | ["8-12", "12:30-17"], # Wednesday 53 | ["8-12", "12:30-17"], # Thursday 54 | ["8-12", "12:30-15"], # Friday 55 | ["10-14"], # Saturday 56 | [], # Sunday 57 | ] 58 | 59 | hours_bob = [ 60 | ["15-21"], # Monday 61 | ["15-21"], # Tuesday 62 | ["15-21"], # Wednesday 63 | ["15-21"], # Thursday 64 | ["12-18"], # Friday 65 | [], # Saturday 66 | ["10-14"], # Sunday 67 | ] 68 | 69 | 70 | schedule_alice = timedeltarangeset( 71 | str_to_timedeltarange(span).offset(timedelta(day)) 72 | for day, spans in enumerate(hours_alice) 73 | for span in spans) 74 | 75 | schedule_bob = timedeltarangeset( 76 | str_to_timedeltarange(span).offset(timedelta(day)) 77 | for day, spans in enumerate(hours_bob) 78 | for span in spans) 79 | 80 | 81 | # Print hours where both Alice and Bob work 82 | day_names = { 83 | 0: "Monday", 84 | 1: "Tuesday", 85 | 2: "Wednesday", 86 | 3: "Thursday", 87 | 4: "Friday", 88 | 5: "Saturday", 89 | 6: "Sunday", 90 | } 91 | for span in schedule_alice.intersection(schedule_bob): 92 | print(u"{: <10} {}".format( 93 | day_names[span.lower.days], 94 | timedeltarange_to_str(span))) 95 | 96 | This code outputs: 97 | 98 | .. testoutput:: 99 | 100 | Monday 15:00-17:00 101 | Tuesday 15:00-17:00 102 | Wednesday 15:00-17:00 103 | Thursday 15:00-17:00 104 | Friday 12:30-15:00 105 | -------------------------------------------------------------------------------- /doc/ranges.rst: -------------------------------------------------------------------------------- 1 | Ranges 2 | ====== 3 | Ranges are like `intervals in mathematics `_. They have a start and end. Every value between the enpoints is included in the range. Integer ranges (:class:`~spans.types.intrange`) will be used for all examples. Built in range types are listed in `Available range types`_. 4 | 5 | A simple range: 6 | 7 | .. code-block:: python 8 | 9 | >>> span = intrange(1, 5) 10 | >>> span.lower 11 | 1 12 | >>> span.upper 13 | 5 14 | 15 | By default all ranges include all elements from and including `lower` up to but not including `upper`. This means that the last element included in the discrete :class:`~spans.types.intrange` is `4`. 16 | 17 | .. code-block:: python 18 | 19 | >>> intrange(1, 5).last 20 | 4 21 | 22 | Non discrete ranges, such as :class:`~spans.types.floatrange`, do not have the property last :class:`~spans.types.DiscreteRange.last`. 23 | 24 | Discrete ranges are always normalized, while normal ranges are not. 25 | 26 | .. code-block:: python 27 | 28 | >>> intrange(1, 5, upper_inc=True) 29 | intrange(1, 6) 30 | >>> floatrange(1.0, 5.0, upper_inc=True) 31 | floatrange(1.0, 5.0, upper_inc=True) 32 | 33 | Ranges support set operations such as :class:`~spans.types.Range.union`, :class:`~spans.types.Range.difference` and :class:`~spans.types.Range.intersection`. 34 | 35 | .. code-block:: python 36 | 37 | >>> intrange(1, 5).union(intrange(5, 10)) 38 | intrange(1, 10) 39 | >>> intrange(1, 10).difference(intrange(5, 15)) 40 | intrange(1, 5) 41 | >>> intrange(1, 10).intersection(intrange(5, 15)) 42 | intrange(5, 10) 43 | 44 | Unions and differences that would result in two sets will result in a ``ValueError``. To perform such operations `Range sets`_ must be used. 45 | 46 | .. code-block:: python 47 | 48 | >>> intrange(1, 5).union(intrange(10, 15)) 49 | Traceback (most recent call last): 50 | File "", line 1, in 51 | ValueError: Ranges must be either adjacent or overlapping 52 | 53 | .. note:: 54 | This behavior is for consistency with PostgreSQL. 55 | 56 | Available range types 57 | --------------------- 58 | The following range types are built in: 59 | 60 | - Integer range (:class:`~spans.types.intrange`) 61 | - Float range (:class:`~spans.types.floatrange`) 62 | - String range (:class:`~spans.types.strrange`) which operate on unicode strings 63 | - Date range (:class:`~spans.types.daterange`) 64 | - Datetime range (:class:`~spans.types.datetimerange`) 65 | - Timedelta range (:class:`~spans.types.timedeltarange`) 66 | 67 | Range sets 68 | ---------- 69 | Range sets are sets of intervals, where each element must be represented by one and only one range. Range sets are the solution to the problem when an operation will result in two separate ranges. 70 | 71 | .. code-block:: python 72 | 73 | >>> intrangeset([intrange(1, 5), intrange(10, 15)]) 74 | intrangeset([intrange(1, 5), intrange(10, 15)]) 75 | 76 | Like ranges, range sets support :class:`~spans.settypes.rangeset.union`, :class:`~spans.settypes.rangeset.difference` and :class:`~spans.settypes.rangeset.intersection`. Contrary to Python's built in sets these operations do not modify the range set in place. Instead it returns a new set. Unchanged ranges are reused to conserve memory since ranges are immutable. 77 | 78 | Range sets are however mutable structures. To modify an existing set in place the :class:`~spans.settypes.rangeset.add` and :class:`~spans.settypes.rangeset.remove` methods are used. 79 | 80 | .. code-block:: python 81 | 82 | >>> span = intrangeset([intrange(1, 5)]) 83 | >>> span.add(intrange(5, 10)) 84 | >>> span 85 | intrangeset([intrange(1, 10)]) 86 | >>> span.remove(intrange(3, 7)) 87 | >>> span 88 | intrangeset([intrange(1, 3), intrange(7, 10)]) 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /doc/postgresql.rst: -------------------------------------------------------------------------------- 1 | PostgreSQL analogies 2 | ==================== 3 | This page describes range types, functions and operators in PostgreSQL, and what 4 | their Spans equivalents are. 5 | 6 | 7 | Range types 8 | ----------- 9 | Most range types included in Spans have an equivalent in PostgreSQL. 10 | 11 | ================================== ================================================================================================== 12 | Postgresql type Python type 13 | ================================== ================================================================================================== 14 | ``int4range`` :class:`~spans.types.intrange` 15 | ``int8range`` :class:`~spans.types.intrange` 16 | ``numrange`` :class:`~spans.types.floatrange`, though :class:`~spans.types.floatrange` does not accept integers 17 | ``tsrange`` :class:`~spans.types.datetimerange` 18 | ``tstzrange`` :class:`~spans.types.datetimerange` 19 | ``daterange`` :class:`~spans.types.daterange` 20 | `Does not exist` [#intervalrange]_ :class:`~spans.types.timedeltarange` 21 | `Does not exist` :class:`~spans.types.strrange` 22 | ================================== ================================================================================================== 23 | 24 | 25 | Operators 26 | --------- 27 | Most operators are not overloaded in Python to their PostgreSQL equivalents. 28 | Instead Spans implements the functionality using methods. 29 | 30 | =============================== ======================== ================================== 31 | Operator PostgreSQL Python 32 | =============================== ======================== ================================== 33 | Equal ``a = b`` ``a == b`` 34 | Not equal ``a != b`` or ``a <> b`` ``a != b`` 35 | Less than ``a < b`` ``a < b`` 36 | Greater than ``a > b`` ``a > b`` 37 | Less than or equal ``a < b`` ``a < b`` 38 | Greater than or equal ``a > b`` ``a > b`` 39 | Contains ``a @> b`` ``a.contains(b)`` 40 | Is contained by ``a <@ b`` ``a in b`` or ``a.within(b)`` 41 | Overlap ``a && b`` ``a.overlap(b)`` 42 | Strictly left of ``a << b`` ``a.left_of(b)`` or ``a << b`` 43 | Strictly right of ``a >> b`` ``a.right_of(b)`` or ``a >> b`` 44 | Does not extend to the right of ``a &< b`` ``a.endsbefore(b)`` 45 | Does not extend to the left of ``a &> b`` ``a.startsafter(b)`` 46 | Is adjacent to ``a -|- b`` ``a.adjacent(b)`` 47 | Union ``a + b`` ``a.union(b)`` or ``a | b`` 48 | Intersection ``a * b`` ``a.intersection(b)`` or ``a & b`` 49 | Difference ``a - b`` ``a.difference(b)`` or ``a - b`` 50 | =============================== ======================== ================================== 51 | 52 | 53 | Functions 54 | --------- 55 | There are no functions in Spans that operate on ranges. Instead they are 56 | implemented as methods, properties or very simple combinations. 57 | 58 | =================== ============================== 59 | PostgreSQL function Python equivalent 60 | =================== ============================== 61 | ``lower(a)`` ``a.lower`` 62 | ``upper(a)`` ``a.upper`` 63 | ``isempty(a)`` ``a.upper`` 64 | ``lower_inc(a)`` ``a.lower_inc`` 65 | ``upper_inc(a)`` ``a.upper_inc`` 66 | ``lower_inf(a)`` ``a.lower_inf`` 67 | ``upper_inf(a)`` ``a.upper_inf`` 68 | ``range_merge(a)`` ``intrangeset([a, b]).span()`` 69 | =================== ============================== 70 | 71 | 72 | .. [#intervalrange] Though it is not built in it can be created using: 73 | ``CREATE TYPE intervalrange AS RANGE(SUBTYPE = interval);`` 74 | -------------------------------------------------------------------------------- /tests/test_periodrange.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | 5 | from spans import PeriodRange, daterange 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "period", 10 | [ 11 | "day", 12 | "week", 13 | "american_week", 14 | "month", 15 | "quarter", 16 | "year", 17 | ], 18 | ) 19 | def test_type(period): 20 | span = PeriodRange.from_date(date(2000, 1, 1), period=period) 21 | assert span.period == period 22 | 23 | 24 | def test_empty_type_error(): 25 | with pytest.raises(TypeError): 26 | PeriodRange.empty() 27 | 28 | 29 | def test_daterange_property(): 30 | daterange_span = daterange.from_date(date(2000, 1, 1), period="month") 31 | span = PeriodRange.from_date(date(2000, 1, 1), period="month") 32 | 33 | assert type(span.daterange) is daterange 34 | assert daterange_span == span.daterange 35 | 36 | 37 | def test_daterange_subclass(): 38 | assert issubclass(PeriodRange, daterange) 39 | 40 | 41 | def test_replace(): 42 | span_2000 = PeriodRange.from_year(2000) 43 | span = span_2000.replace(upper=date(2002, 1, 1)) 44 | 45 | daterange_2000 = daterange.from_year(2000) 46 | daterange_2001 = daterange.from_year(2001) 47 | daterange_span = daterange_2000.union(daterange_2001) 48 | 49 | assert type(span) is type(daterange_span) 50 | assert span == daterange_span 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "range_type_a, range_type_b", 55 | [ 56 | (PeriodRange, PeriodRange), 57 | (daterange, PeriodRange), 58 | (PeriodRange, daterange), 59 | ], 60 | ) 61 | def test_union(range_type_a, range_type_b): 62 | span_2000 = range_type_a.from_year(2000) 63 | span_2001 = range_type_b.from_year(2001) 64 | 65 | span = span_2000.union(span_2001) 66 | 67 | assert span.lower == date(2000, 1, 1) 68 | assert span.upper == date(2002, 1, 1) 69 | assert type(span) is daterange 70 | 71 | 72 | @pytest.mark.parametrize( 73 | "range_type_a, range_type_b", 74 | [ 75 | (PeriodRange, PeriodRange), 76 | (daterange, PeriodRange), 77 | (PeriodRange, daterange), 78 | ], 79 | ) 80 | def test_intersection(range_type_a, range_type_b): 81 | span_a = range_type_a.from_week(2000, 1) 82 | span_b = range_type_b.from_month(2000, 1) 83 | 84 | span = span_a.intersection(span_b) 85 | 86 | assert span.lower == date(2000, 1, 3) 87 | assert span.upper == date(2000, 1, 10) 88 | assert type(span) is daterange 89 | 90 | 91 | @pytest.mark.parametrize( 92 | "range_type_a, range_type_b", 93 | [ 94 | (PeriodRange, PeriodRange), 95 | (daterange, PeriodRange), 96 | (PeriodRange, daterange), 97 | ], 98 | ) 99 | def test_difference(range_type_a, range_type_b): 100 | span_a = range_type_a.from_quarter(2000, 1) 101 | span_b = range_type_b.from_month(2000, 1) 102 | 103 | span = span_a.difference(span_b) 104 | 105 | assert span.lower == date(2000, 2, 1) 106 | assert span.upper == date(2000, 4, 1) 107 | assert type(span) is daterange 108 | 109 | 110 | @pytest.mark.parametrize( 111 | "a, b", 112 | [ 113 | (PeriodRange.from_week(1999, 52), PeriodRange.from_week(2000, 1)), 114 | (PeriodRange.from_week(2000, 1), PeriodRange.from_week(2000, 2)), 115 | (PeriodRange.from_week(2009, 53), PeriodRange.from_week(2010, 1)), 116 | ], 117 | ) 118 | def test_prev_next_period(a, b): 119 | assert a.next_period() == b 120 | assert a == b.prev_period() 121 | 122 | 123 | @pytest.mark.parametrize( 124 | "a, offset, b", 125 | [ 126 | (PeriodRange.from_week(2000, 1), 52, PeriodRange.from_week(2001, 1)), 127 | (PeriodRange.from_month(2000, 1), 12, PeriodRange.from_month(2001, 1)), 128 | (PeriodRange.from_quarter(2000, 1), 4, PeriodRange.from_quarter(2001, 1)), 129 | (PeriodRange.from_year(2000), 10, PeriodRange.from_year(2010)), 130 | ], 131 | ) 132 | def test_offset(a, offset, b): 133 | assert a.offset(offset) == b 134 | assert a == b.offset(-offset) 135 | 136 | 137 | def test_zero_offset(): 138 | period = PeriodRange.from_week(2000, 1) 139 | assert period.offset(0) is period 140 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Spans 2 | ===== 3 | |pypi-version| |py-versions| |license| 4 | 5 | Spans is a pure Python implementation of PostgreSQL's 6 | `range types `_. 7 | Range types are convenient when working with intervals of any kind. Every time 8 | you've found yourself working with date_start and date_end, an interval may have 9 | been what you were actually looking for. 10 | 11 | Spans has successfully been used in production since its first release 12 | 30th August, 2013. 13 | 14 | 15 | Installation 16 | ------------ 17 | Spans exists on PyPI. 18 | 19 | .. code-block:: bash 20 | 21 | $ pip install Spans 22 | 23 | `Documentation `_ is hosted on Read the 24 | Docs. 25 | 26 | 27 | Example 28 | ------- 29 | Imagine you are building a calendar and want to display all weeks that overlaps 30 | the current month. Normally you have to do some date trickery to achieve this, 31 | since the month's bounds may be any day of the week. With Spans' set-like 32 | operations and shortcuts the problem becomes a breeze. 33 | 34 | We start by importing ``date`` and ``daterange`` 35 | 36 | .. code-block:: python 37 | 38 | >>> from datetime import date 39 | >>> from spans import daterange 40 | 41 | Using ``daterange.from_month`` we can get range representing January in the year 42 | 2000 43 | 44 | .. code-block:: python 45 | 46 | >>> month = daterange.from_month(2000, 1) 47 | >>> month 48 | daterange(datetime.date(2000, 1, 1), datetime.date(2000, 2, 1)) 49 | 50 | Now we can calculate the ranges for the weeks where the first and last day of 51 | month are 52 | 53 | .. code-block:: python 54 | 55 | >>> start_week = daterange.from_date(month.lower, period="week") 56 | >>> end_week = daterange.from_date(month.last, period="week") 57 | >>> start_week 58 | daterange(datetime.date(1999, 12, 27), datetime.date(2000, 1, 3)) 59 | >>> end_week 60 | daterange(datetime.date(2000, 1, 31), datetime.date(2000, 2, 7)) 61 | 62 | Using a union we can express the calendar view. 63 | 64 | .. code-block:: python 65 | 66 | >>> start_week.union(month).union(end_week) 67 | daterange(datetime.date(1999, 12, 27), datetime.date(2000, 2, 7)) 68 | 69 | Do you want to know more? Head over to the 70 | `documentation `_. 71 | 72 | 73 | Use with Psycopg2 74 | ----------------- 75 | To use these range types with Psycopg2 the 76 | `PsycoSpans `_. 77 | 78 | 79 | Motivation 80 | ---------- 81 | For a project of mine I started using PostgreSQL's ``tsrange`` type and needed 82 | an equivalent in Python. These range types attempt to mimick PostgreSQL's 83 | behavior in every way. Deviating from it is considered as a bug and should be 84 | reported. 85 | 86 | 87 | Contribute 88 | ---------- 89 | I appreciate all the help I can get! Some things to think about: 90 | 91 | - If it's a simple fix, such as documentation or trivial bug fix, please file 92 | an issue or submit a pull request. Make sure to only touch lines relevant to 93 | the issue. I don't accept pull requests that simply reformat the code to be 94 | PEP8-compliant. To me the history of the repository is more important. 95 | - If it's a feature request or a non-trivial bug, always open an issue first to 96 | discuss the matter. It would be a shame if good work went to waste because a 97 | pull request doesn't fit the scope of this project. 98 | 99 | Pull requests are credited in the change log which is displayed on PyPI and the 100 | documentaion on Read the Docs. 101 | 102 | 103 | .. |pypi-version| image:: https://badge.fury.io/py/Spans.svg 104 | :alt: PyPI version status 105 | :scale: 100% 106 | :target: https://pypi.python.org/pypi/Spans/ 107 | 108 | .. |py-versions| image:: https://img.shields.io/pypi/pyversions/Spans.svg 109 | :alt: Python version 110 | :scale: 100% 111 | :target: https://pypi.python.org/pypi/Spans/ 112 | 113 | .. |license| image:: https://img.shields.io/github/license/runfalk/spans.svg 114 | :alt: MIT License 115 | :scale: 100% 116 | :target: https://github.com/runfalk/spans/blob/master/LICENSE 117 | 118 | .. Include changelog on PyPI 119 | 120 | .. include:: doc/changelog.rst 121 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # This avoids having duplicate builds for a pull request 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | lint: 14 | name: Static analysis 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Install Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.10" 23 | - name: Install Poetry 24 | uses: abatilo/actions-poetry@v2.0.0 25 | with: 26 | poetry-version: 1.1.13 27 | - name: Install dev dependencies 28 | run: poetry install 29 | - name: Check code formatting 30 | run: poetry run black --check spans tests 31 | - name: Check import ordering 32 | run: poetry run isort --check spans tests 33 | 34 | tests: 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python-version: ["3.7", "3.8", "3.9", "3.10"] 39 | os: ["ubuntu-latest"] 40 | name: Pytest (${{ matrix.python-version }}, ${{ matrix.os }}) 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - name: Checkout code 44 | uses: actions/checkout@v2 45 | - name: Install Python 46 | uses: actions/setup-python@v2 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | - name: Install Poetry 50 | uses: abatilo/actions-poetry@v2.0.0 51 | with: 52 | poetry-version: 1.1.13 53 | # Remove the lock file unless we're using the stable version of Python. 54 | # This is required because older Python version require "polyfills" for 55 | # some libraries 56 | - name: Remove poetry.lock file 57 | run: rm poetry.lock 58 | if: ${{ matrix.python-version != '3.10' }} 59 | - name: Install dev dependencies 60 | run: poetry install 61 | - name: Run pytest 62 | run: poetry run pytest 63 | 64 | docs: 65 | name: Documentation 66 | runs-on: ubuntu-latest 67 | needs: 68 | - lint 69 | - tests 70 | steps: 71 | - name: Checkout code 72 | uses: actions/checkout@v2 73 | - name: Install Python 74 | uses: actions/setup-python@v2 75 | with: 76 | python-version: "3.10" 77 | - name: Install Poetry 78 | uses: abatilo/actions-poetry@v2.0.0 79 | with: 80 | poetry-version: 1.1.13 81 | - name: Install dev dependencies 82 | run: poetry install 83 | - name: Build documentation 84 | run: | 85 | poetry run sphinx-build -b html doc doc-build 86 | touch doc-build/.nojekyll 87 | - name: Deploy 88 | uses: crazy-max/ghaction-github-pages@v3 89 | if: ${{ github.event_name == 'create' && github.event.ref_type == 'tag' && github.ref == 'refs/heads/${{ github.event.repository.default_branch }}' }} 90 | with: 91 | allow_empty_commit: false 92 | build_dir: doc-build/ 93 | author: Andreas Runfalk 94 | keep_history: true 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | 98 | build: 99 | name: Build and deploy 100 | runs-on: ubuntu-latest 101 | needs: 102 | - lint 103 | - tests 104 | - docs 105 | steps: 106 | - name: Checkout code 107 | uses: actions/checkout@v2 108 | - name: Install Python 109 | uses: actions/setup-python@v2 110 | with: 111 | python-version: "3.10" 112 | - name: Install Poetry 113 | uses: abatilo/actions-poetry@v2.0.0 114 | with: 115 | poetry-version: 1.1.13 116 | - name: Install dev dependencies 117 | run: poetry install 118 | - name: Validate that version matches the tag 119 | if: ${{ github.event_name == 'create' && github.event.ref_type == 'tag' && github.ref == 'refs/heads/${{ github.event.repository.default_branch }}' }} 120 | run: test "$(poetry version --short)" == "${{ github.ref_name }}" 121 | - name: Build package 122 | run: poetry build 123 | - name: Deploy 124 | if: ${{ github.event_name == 'create' && github.event.ref_type == 'tag' && github.ref == 'refs/heads/${{ github.event.repository.default_branch }}' }} 125 | env: 126 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} 127 | run: poetry publish 128 | -------------------------------------------------------------------------------- /tests/test_daterange.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timedelta 2 | 3 | import pytest 4 | 5 | from spans import daterange 6 | 7 | 8 | def test_datetime_type_check(): 9 | with pytest.raises(TypeError): 10 | daterange(datetime(2000, 1, 1)) 11 | 12 | with pytest.raises(TypeError): 13 | daterange(upper=datetime(2000, 1, 1)) 14 | 15 | 16 | def test_offset(): 17 | range_low = daterange(date(2000, 1, 1), date(2000, 1, 6)) 18 | range_high = daterange(date(2000, 1, 5), date(2000, 1, 10)) 19 | 20 | assert range_low != range_high 21 | assert range_low.offset(timedelta(days=4)) == range_high 22 | assert range_low == range_high.offset(timedelta(days=-4)) 23 | 24 | 25 | def test_from_date(): 26 | date_start = date(2000, 1, 1) 27 | assert daterange.from_date(date_start) == daterange( 28 | date_start, date_start + timedelta(1) 29 | ) 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "day, span", 34 | [ 35 | (date(2000, 1, 1), daterange(date(1999, 12, 27), date(2000, 1, 3))), 36 | (date(2000, 1, 2), daterange(date(1999, 12, 27), date(2000, 1, 3))), 37 | (date(2000, 1, 3), daterange(date(2000, 1, 3), date(2000, 1, 10))), 38 | ], 39 | ) 40 | def test_from_date_week(day, span): 41 | assert daterange.from_date(day, period="week") == span 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "day, span", 46 | [ 47 | (date(2000, 1, 1), daterange(date(1999, 12, 26), date(2000, 1, 2))), 48 | (date(2000, 1, 2), daterange(date(2000, 1, 2), date(2000, 1, 9))), 49 | (date(2000, 1, 3), daterange(date(2000, 1, 2), date(2000, 1, 9))), 50 | ], 51 | ) 52 | def test_from_date_american_week(day, span): 53 | assert daterange.from_date(day, period="american_week") == span 54 | 55 | 56 | @pytest.mark.parametrize( 57 | "day, span", 58 | [ 59 | ( 60 | date(2000, 1, 1), 61 | daterange(date(2000, 1, 1), date(2000, 1, 31), upper_inc=True), 62 | ), 63 | ( 64 | date(2000, 2, 15), 65 | daterange(date(2000, 2, 1), date(2000, 2, 29), upper_inc=True), 66 | ), 67 | ( 68 | date(2001, 2, 15), 69 | daterange(date(2001, 2, 1), date(2001, 2, 28), upper_inc=True), 70 | ), 71 | ], 72 | ) 73 | def test_from_date_month(day, span): 74 | assert daterange.from_date(day, period="month") == span 75 | 76 | 77 | @pytest.mark.parametrize( 78 | "day, span", 79 | [ 80 | ( 81 | date(2000, 1, 1), 82 | daterange(date(2000, 1, 1), date(2000, 3, 31), upper_inc=True), 83 | ), 84 | ( 85 | date(2000, 2, 15), 86 | daterange(date(2000, 1, 1), date(2000, 3, 31), upper_inc=True), 87 | ), 88 | ( 89 | date(2000, 3, 31), 90 | daterange(date(2000, 1, 1), date(2000, 3, 31), upper_inc=True), 91 | ), 92 | ], 93 | ) 94 | def test_from_date_quarter(day, span): 95 | assert daterange.from_date(day, period="quarter") == span 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "day, span", 100 | [ 101 | (date(2000, 1, 1), daterange(date(2000, 1, 1), date(2001, 1, 1))), 102 | (date(2000, 6, 1), daterange(date(2000, 1, 1), date(2001, 1, 1))), 103 | (date(2000, 12, 31), daterange(date(2000, 1, 1), date(2001, 1, 1))), 104 | ], 105 | ) 106 | def test_from_date_year(day, span): 107 | assert daterange.from_date(day, period="year") == span 108 | 109 | 110 | @pytest.mark.parametrize( 111 | "param", 112 | [ 113 | True, 114 | 1, 115 | 1.0, 116 | datetime(2000, 1, 1), 117 | ], 118 | ) 119 | def test_from_date_type_check(param): 120 | with pytest.raises(TypeError): 121 | daterange.from_date(param) 122 | 123 | 124 | @pytest.mark.parametrize( 125 | "period", 126 | [ 127 | "Year", 128 | "YEAR", 129 | "foobar", 130 | ], 131 | ) 132 | def test_from_date_period_check(period): 133 | with pytest.raises(ValueError): 134 | daterange.from_date(date(2000, 1, 1), period=period) 135 | 136 | 137 | @pytest.mark.parametrize( 138 | "year, iso_week, first_day", 139 | [ 140 | (2000, 1, date(2000, 1, 3)), 141 | (2000, 2, date(2000, 1, 10)), 142 | (2000, 3, date(2000, 1, 17)), 143 | (2000, 4, date(2000, 1, 24)), 144 | ], 145 | ) 146 | def test_from_week(year, iso_week, first_day): 147 | assert daterange.from_week(year, iso_week) == daterange.from_date( 148 | first_day, period="week" 149 | ) 150 | 151 | 152 | @pytest.mark.parametrize( 153 | "year", 154 | [ 155 | 2000, 156 | 2001, 157 | ], 158 | ) 159 | @pytest.mark.parametrize("month", range(1, 13)) 160 | def test_from_month(year, month): 161 | assert daterange.from_month(year, month) == daterange.from_date( 162 | date(year, month, 1), period="month" 163 | ) 164 | 165 | 166 | @pytest.mark.parametrize( 167 | "year, quarter, first_day", 168 | [ 169 | (2000, 1, date(2000, 1, 1)), 170 | (2000, 2, date(2000, 4, 1)), 171 | (2000, 3, date(2000, 7, 1)), 172 | (2000, 4, date(2000, 10, 1)), 173 | ], 174 | ) 175 | def test_from_quarter(year, quarter, first_day): 176 | assert daterange.from_quarter(year, quarter) == daterange.from_date( 177 | first_day, period="quarter" 178 | ) 179 | 180 | 181 | @pytest.mark.parametrize("quarter", [0, 5]) 182 | def test_from_quarter_value_check(quarter): 183 | with pytest.raises(ValueError): 184 | assert daterange.from_quarter(2000, quarter) 185 | 186 | 187 | @pytest.mark.parametrize( 188 | "year", 189 | [ 190 | 2000, 191 | 2001, 192 | ], 193 | ) 194 | def test_from_year(year): 195 | assert daterange.from_year(year) == daterange.from_date( 196 | date(year, 1, 1), period="year" 197 | ) 198 | 199 | 200 | def test_last(): 201 | span = daterange(date(2000, 1, 1), date(2000, 2, 1)) 202 | assert span.last == date(2000, 1, 31) 203 | 204 | 205 | def test_len_on_unbounded(): 206 | with pytest.raises(ValueError): 207 | len(daterange()) 208 | 209 | with pytest.raises(ValueError): 210 | len(daterange(date(2000, 1, 1))) 211 | 212 | with pytest.raises(ValueError): 213 | len(daterange(upper=date(2000, 1, 1))) 214 | 215 | 216 | def test_bug5_date_subclassing(): 217 | """ 218 | `Bug #5 ` 219 | """ 220 | 221 | class DateSubClass(date): 222 | pass 223 | 224 | daterange(DateSubClass(2000, 1, 1)) 225 | -------------------------------------------------------------------------------- /tests/test_rangeset.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | import pytest 4 | 5 | from spans import ( 6 | daterangeset, 7 | datetimerangeset, 8 | floatrange, 9 | floatrangeset, 10 | intrange, 11 | intrangeset, 12 | strrangeset, 13 | timedeltarangeset, 14 | ) 15 | 16 | 17 | def test_empty(): 18 | assert not intrangeset([]) 19 | 20 | 21 | def test_non_empty(): 22 | assert intrangeset([intrange(1, 5)]) 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "rangeset, span", 27 | [ 28 | (intrangeset([intrange(1, 5), intrange(10, 15)]), intrange(1, 15)), 29 | (intrangeset([]), intrange.empty()), 30 | ], 31 | ) 32 | def test_span(rangeset, span): 33 | assert rangeset.span() == span 34 | 35 | 36 | def test_iteration(): 37 | ranges = [intrange(1, 5), intrange(10, 15)] 38 | assert list(intrangeset(ranges)) == ranges 39 | 40 | 41 | def test_copy(): 42 | rset = intrangeset([intrange(1, 5), intrange(10, 15)]) 43 | rcopy = rset.copy() 44 | 45 | assert list(rset) == list(rcopy) 46 | assert rset._list is not rcopy._list 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "value", 51 | [ 52 | intrange(1, 5), 53 | intrange(5, 10), 54 | intrange.empty(), 55 | 1, 56 | 5, 57 | ], 58 | ) 59 | def test_contains(value): 60 | assert intrangeset([intrange(1, 10)]).contains(value) 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "value", 65 | [ 66 | intrange(5, 15), 67 | 10, 68 | ], 69 | ) 70 | def test_not_contains(value): 71 | assert not intrangeset([intrange(1, 10)]).contains(value) 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "rset", 76 | [ 77 | intrangeset([]), 78 | intrangeset([intrange(1, 5)]), 79 | ], 80 | ) 81 | def test_contains_empty(rset): 82 | assert rset.contains(intrange.empty()) 83 | 84 | 85 | def test_contains_type_check(): 86 | with pytest.raises(ValueError): 87 | intrangeset([]).contains(1.0) 88 | 89 | with pytest.raises(ValueError): 90 | intrangeset([]).contains(floatrangeset([])) 91 | 92 | 93 | def test_add(): 94 | rset = intrangeset([intrange(1, 15)]) 95 | rset.add(intrange(5, 15)) 96 | 97 | assert list(rset) == [intrange(1, 15)] 98 | 99 | with pytest.raises(TypeError): 100 | rset.add(floatrange(1.0)) 101 | 102 | 103 | def test_remove(): 104 | rset = intrangeset([intrange(upper=1), intrange(5)]) 105 | rset.remove(intrange(10, 15)) 106 | 107 | assert rset == intrangeset([intrange(upper=1), intrange(5, 10), intrange(15)]) 108 | 109 | # Test deletion of empty set 110 | temp = rset.copy() 111 | temp.remove(intrange.empty()) 112 | assert rset == temp 113 | 114 | # Test total deletion 115 | rset.remove(intrange()) 116 | assert rset == intrangeset([]) 117 | 118 | # Test deletion on empty set 119 | temp = intrangeset([]) 120 | temp.remove(intrange(1, 5)) 121 | assert temp == intrangeset([]) 122 | 123 | with pytest.raises(TypeError): 124 | rset.remove(floatrange(1.0)) 125 | 126 | 127 | def test_invert(): 128 | rset = intrangeset([intrange(1, 5), intrange(10, 15)]) 129 | rset_inv = intrangeset([intrange(upper=1), intrange(5, 10), intrange(15)]) 130 | 131 | assert ~rset == rset_inv 132 | assert rset == ~~rset 133 | 134 | 135 | def test_union(): 136 | a = intrangeset([intrange(1, 5), intrange(20, 30)]) 137 | b = intrangeset([intrange(5, 10), intrange(20, 100)]) 138 | union = [intrange(1, 10), intrange(20, 100)] 139 | 140 | assert list(a.union(b)) == union 141 | assert list(a | b) == union 142 | 143 | with pytest.raises(TypeError): 144 | intrangeset([]).union(intrange()) 145 | with pytest.raises(TypeError): 146 | intrangeset([]) | intrange() 147 | 148 | 149 | def test_difference(): 150 | a = intrangeset([intrange(1, 5), intrange(20, 30)]) 151 | b = intrangeset([intrange(5, 10), intrange(20, 100)]) 152 | difference = [intrange(1, 5)] 153 | 154 | assert list(a.difference(b)) == difference 155 | assert list(a - b) == difference 156 | 157 | with pytest.raises(TypeError): 158 | intrangeset([]).difference(intrange()) 159 | with pytest.raises(TypeError): 160 | intrangeset([]) - intrange() 161 | 162 | 163 | def test_intersection(): 164 | a = intrangeset([intrange(1, 5), intrange(20, 30)]) 165 | b = intrangeset([intrange(5, 10), intrange(20, 100)]) 166 | intersection = [intrange(20, 30)] 167 | 168 | assert list(a.intersection(b)) == intersection 169 | assert list(a & b) == intersection 170 | assert not intrangeset([intrange(1, 5)]).intersection( 171 | intrangeset([intrange(5, 10)]) 172 | ) 173 | 174 | with pytest.raises(TypeError): 175 | intrangeset([]).intersection(intrange()) 176 | with pytest.raises(TypeError): 177 | intrangeset([]) & intrange() 178 | 179 | 180 | def test_values(): 181 | values = intrangeset([intrange(1, 5), intrange(10, 15)]).values() 182 | assert list(values) == list(range(1, 5)) + list(range(10, 15)) 183 | 184 | 185 | @pytest.mark.parametrize( 186 | "span, repr_str", 187 | [ 188 | (intrangeset([]), "intrangeset([])"), 189 | (intrangeset([intrange(1)]), "intrangeset([intrange(1)])"), 190 | ], 191 | ) 192 | def test_repr(span, repr_str): 193 | assert repr(span) == repr_str 194 | 195 | 196 | def test_pickling(): 197 | span = intrangeset([intrange(1, 10), intrange(20, 30)]) 198 | assert span == pickle.loads(pickle.dumps(span)) 199 | 200 | 201 | def test_equal(): 202 | range_a = intrange(1, 5) 203 | range_b = intrange(10, 15) 204 | 205 | assert intrangeset([range_a, range_b]) == intrangeset([range_a, range_b]) 206 | assert not intrangeset([range_a, range_b]) == intrangeset([range_a]) 207 | assert not intrangeset([range_a]) == "foo" 208 | 209 | 210 | def test_less_than(): 211 | range_a = intrange(1, 5) 212 | range_b = intrange(10, 15) 213 | 214 | assert not intrangeset([range_a, range_b]) < intrangeset([range_a]) 215 | assert intrangeset([range_a, range_b]) < intrangeset([range_b]) 216 | assert not intrangeset([range_a, range_b]) <= intrangeset([range_a]) 217 | assert not intrangeset([range_a]) == "foo" 218 | 219 | 220 | def test_greater_than(): 221 | range_a = intrange(1, 5) 222 | range_b = intrange(10, 15) 223 | 224 | assert intrangeset([range_a, range_b]) > intrangeset([range_a]) 225 | assert not intrangeset([range_a, range_b]) > intrangeset([range_b]) 226 | assert intrangeset([range_b]) > intrangeset([range_a, range_b]) 227 | assert intrangeset([range_a, range_b]) >= intrangeset([range_a]) 228 | 229 | 230 | def test_bug3_intersection(): 231 | """ 232 | `Bug #3 `_ 233 | """ 234 | 235 | range_a = intrange(1, 5) 236 | range_b = intrange(5, 10) 237 | range_c = intrange(10, 15) 238 | 239 | rangeset_a = intrangeset([range_a, range_c]) 240 | rangeset_b = intrangeset([range_b]) 241 | rangeset_c = intrangeset([range_c]) 242 | rangeset_empty = intrangeset([]) 243 | 244 | assert rangeset_a.intersection(rangeset_b, rangeset_c) == rangeset_empty 245 | 246 | 247 | def test_bug4_empty_set_iteration(): 248 | """ 249 | `Bug #4 `_ 250 | """ 251 | 252 | assert list(intrangeset([])) == [] 253 | 254 | 255 | @pytest.mark.parametrize( 256 | "cls", 257 | [ 258 | daterangeset, 259 | datetimerangeset, 260 | intrangeset, 261 | floatrangeset, 262 | strrangeset, 263 | timedeltarangeset, 264 | ], 265 | ) 266 | def test_bug10_missing_slots_in_cls_hierarchy(cls): 267 | """ 268 | `Bug #10 ..``. The 4 | first `0.1` release does not properly adhere to this. Unless explicitly stated, 5 | changes are made by `Andreas Runfalk `_. 6 | 7 | 8 | Version 2.0.0 9 | ------------- 10 | Released on UNRELEASED 11 | 12 | This release modernizes Spans and gets rid of legacy bits. 13 | 14 | - Drop Python 2.7 support 15 | - Drop Python 3.4 support (might still work but it's unsupported) 16 | - Drop Python 3.5 support (might still work but it's unsupported) 17 | - Drop Python 3.6 support (might still work but it's unsupported) 18 | - Add Python 3.10 support (was working but is now actively tested) 19 | 20 | 21 | Version 1.1.1 22 | ------------- 23 | Released on 21st April, 2021 24 | 25 | - Normalize ranges to be empty when start and end is the same and either bound 26 | is exclusive 27 | (`bug #18 `_, 28 | `lgharibashvili `_) 29 | 30 | 31 | Version 1.1.0 32 | ------------- 33 | Released on 2nd June, 2019 34 | 35 | This release changes a lot of internal implementation details that should 36 | prevent methods from not handling unbounded ranges correctly in the future. 37 | 38 | - Added validation to ensure unbounded ranges are never inclusive 39 | - Changed ``__repr__`` for ranges to be more similar to proper Python syntax. 40 | The old representation looked like mismatched parentheses. For instance 41 | ``floatrange((,10.0])`` becomes ``floatrange(upper=10.0, upper_inc=True)`` 42 | - Dropped Python 3.3 support since it's been EOL for almost two years. It 43 | probably still works but it is no longer tested 44 | - Fixed pickling of empty range sets not working 45 | (`bug #14 `_) 46 | - Fixed :meth:`~spans.types.Range.union` not working properly with unbounded 47 | ranges 48 | - Fixed lowerly unbounded ranges improperly being lower inclusive 49 | - Fixed :meth:`~spans.types.Range.startswith` and 50 | :meth:`~spans.types.Range.endsbefore` being not handling empty ranges 51 | 52 | 53 | Version 1.0.2 54 | ------------- 55 | Released on 22th February, 2019 56 | 57 | - Fixed :meth:`~spans.types.Range.union` when ``upper_inc`` is set to ``True`` 58 | (`bug #11 `_, 59 | `Michael Krahe `_) 60 | 61 | 62 | Version 1.0.1 63 | ------------- 64 | Released on 31st January, 2018 65 | 66 | - Fixed ``PartialOrderingMixin`` not using ``__slots__`` 67 | (`bug #10 `_) 68 | 69 | 70 | Version 1.0.0 71 | ------------- 72 | Released on 8th June, 2017 73 | 74 | - Added ``NotImplemented`` for ``<<`` and ``>>`` operators when there is a type 75 | mismatch 76 | - Added ``|`` operator for unions of :class:`~spans.types.Range` and 77 | ``NotImplemented`` support for :class:`~spans.settypes.RangeSet` 78 | - Added ``&`` operator for intersections of :class:`~spans.types.Range` and 79 | ``NotImplemented`` support for :class:`~spans.settypes.RangeSet` 80 | - Added ``-`` operator for differences of :class:`~spans.types.Range` and 81 | ``NotImplemented`` support for :class:`~spans.settypes.RangeSet` 82 | - Added ``reversed()`` iterator support for :class:`~spans.types.DiscreteRange` 83 | - Fixed overlap with empty range incorrectly returns ``True`` 84 | (`bug #7 `_) 85 | - Fixed issue with :meth:`~spans.types.Range.contains` for scalars on unbounded 86 | ranges 87 | - Fixed type check for :meth:`~spans.types.Range.right_of` 88 | - Fixed type check for :meth:`~spans.settypes.RangeSet.contains` 89 | - Fixed type check for :meth:`~spans.settypes.RangeSet.union` 90 | - Fixed type check for :meth:`~spans.settypes.RangeSet.intersection` 91 | - Fixed type check for :meth:`~spans.settypes.RangeSet.difference` 92 | - Fixed infinite iterators not being supported for 93 | :class:`~spans.types.DiscreteRange` 94 | 95 | 96 | Version 0.5.0 97 | ------------- 98 | Released on 16th April, 2017 99 | 100 | This release is a preparation for a stable 1.0 release. 101 | 102 | - Fixed comparison operators when working with empty or unbounded ranges. They 103 | would previously raise exceptions. Ranges are now partially ordered instead of 104 | totally ordered 105 | - Added more unit tests 106 | - Renamed classes to match :pep:`8#class-names` conventions. This does not apply 107 | to classes that works on built-in that does not follow :pep:`8#class-names`. 108 | - Refactored :meth:`~spans.types.Range.left_of` 109 | - Refactored :meth:`~spans.types.Range.overlap` 110 | - Refactored :meth:`~spans.types.Range.union` 111 | 112 | 113 | Version 0.4.0 114 | ------------- 115 | Released on 20th March, 2017 116 | 117 | This release is called 0.4.1 on PyPI because I messed up the upload. 118 | 119 | - Added new argument to :meth:`~spans.types.daterange.from_date` for working 120 | with different kinds of date intervals. The argument accepts a period of either 121 | ``"day"`` (default), ``"week"`` (ISO week), ``"american_week"`` (starts on 122 | sunday), ``"month"``, ``"quarter"`` or ``"year"``. 123 | - Added new methods to :class:`~spans.types.daterange` for working with different 124 | kinds of date intervals: 125 | :meth:`~spans.types.daterange.from_week`, 126 | :meth:`~spans.types.daterange.from_month`, 127 | :meth:`~spans.types.daterange.from_quarter` and 128 | :meth:`~spans.types.daterange.from_year`. 129 | - Added a new class :class:`~spans.types.PeriodRange` for working with periods 130 | like weeks, months, quarters or years. It inherits all methods from 131 | :class:`~spans.types.daterange` and is aware of its own period type. It 132 | allows things like getting the previous or next week. 133 | - Fixed :class:`~spans.types.daterange` not accepting subclasses of ``date`` 134 | (`bug #5 `_) 135 | - Fixed some broken doctests 136 | - Moved unit tests to `pytest `_ 137 | - Removed `Tox `_ config 138 | - Minor documentation tweaks 139 | 140 | 141 | Version 0.3.0 142 | ------------- 143 | Released on 26th August, 2016 144 | 145 | - Added documentation for :meth:`~spans.settypes.RangeSet.__iter__` 146 | - Fixed intersection of multiple range sets not working correctly 147 | (`bug #3 `_) 148 | - Fixed iteration of :class:`~spans.settypes.RangeSet` returning an empty range 149 | when ``RangeSet`` is empty 150 | (`bug #4 `_) 151 | 152 | .. warning:: 153 | This change is backwards incompatible to code that expect range sets to 154 | always return at least one set when iterating. 155 | 156 | 157 | Version 0.2.1 158 | ------------- 159 | Released on 27th June, 2016 160 | 161 | - Fixed :class:`~spans.settypes.RangeSet` not returning ``NotImplemented`` when 162 | comparing to classes that are not sub classes of ``RangeSet``, pull request 163 | `#2 `_ 164 | (`Michael Krahe `_) 165 | - Updated license in ``setup.py`` to follow 166 | `recommendations `_ 167 | by PyPA 168 | 169 | 170 | Version 0.2.0 171 | ------------- 172 | Released on 22nd December, 2015 173 | 174 | - Added :meth:`~spans.settypes.RangeSet.__len__` to range sets 175 | (`Michael Krahe `_) 176 | - Added :meth:`~spans.settypes.RangeSet.contains` to range sets 177 | (`Michael Krahe `_) 178 | - Added `Sphinx `_ style doc strings to all methods 179 | - Added proper Sphinx documentation 180 | - Added unit tests for uncovered parts, mostly error checking 181 | - Added `wheel `_ to PyPI along with 182 | source distribution 183 | - Fixed a potential bug where comparing ranges of different types would result 184 | in an infinite loop 185 | - Changed meta class implementation for range sets to allow more mixins for 186 | custom range sets 187 | 188 | 189 | Version 0.1.4 190 | ------------- 191 | Released on 15th May, 2015 192 | 193 | - Added :attr:`~spans.types.DiscreteRange.last` property to 194 | :class:`~spans.types.DiscreteRange` 195 | - Added :meth:`~spans.types.daterange.from_date` helper to 196 | :class:`~spans.types.daterange` 197 | - Added more unit tests 198 | - Improved pickle implementation 199 | - Made type checking more strict for date ranges to prevent ``datetime`` from 200 | being allowed in :class:`~spans.types.daterange` 201 | 202 | 203 | Version 0.1.3 204 | ------------- 205 | Released on 27th February, 2015 206 | 207 | - Added :meth:`~spans.types.OffsetableRangeMixin.offset` to some range types 208 | - Added :meth:`~spans.settypes.OffsetableRangeSetMixin.offset` to some range set 209 | types 210 | - Added sanity checks to range boundaries 211 | - Fixed incorrect ``__slots__`` usage, resulting in ``__slots__`` not being used 212 | on most ranges 213 | - Fixed pickling of ranges and range sets 214 | - Simplified creation of new range sets, by the use of the meta class 215 | :class:`~spans.settypes.MetaRangeSet` 216 | 217 | 218 | Version 0.1.2 219 | ------------- 220 | Released on 13th June, 2014 221 | 222 | - Fix for inproper version detection on Ubuntu's bundled Python interpreter 223 | 224 | 225 | Version 0.1.1 226 | ------------- 227 | Released on 12th June, 2014 228 | 229 | - Readme fixes 230 | - Syntax highlighting for PyPI page 231 | 232 | 233 | Version 0.1.0 234 | ------------- 235 | Released on 30th August, 2013 236 | 237 | - Initial release 238 | -------------------------------------------------------------------------------- /tests/test_range.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import operator 3 | import pickle 4 | 5 | import pytest 6 | 7 | from spans import ( 8 | PeriodRange, 9 | daterange, 10 | datetimerange, 11 | floatrange, 12 | intrange, 13 | strrange, 14 | timedeltarange, 15 | ) 16 | from spans.types import _Bound 17 | 18 | 19 | def test_empty(): 20 | empty_range = intrange.empty() 21 | 22 | assert not empty_range 23 | assert empty_range.lower is None 24 | assert empty_range.upper is None 25 | assert not empty_range.lower_inc 26 | assert not empty_range.upper_inc 27 | assert not empty_range.lower_inf 28 | assert not empty_range.upper_inf 29 | 30 | 31 | def test_non_empty(): 32 | assert intrange() 33 | 34 | 35 | def test_bound_helper(): 36 | # This is technically an implementation detail, but since it's used 37 | # everywhere it's good to exhaustively test it 38 | assert _Bound(1, inc=False, is_lower=False) < _Bound(1, inc=False, is_lower=True) 39 | assert _Bound(1, inc=False, is_lower=False) < _Bound(1, inc=True, is_lower=True) 40 | assert _Bound(1, inc=False, is_lower=False) < _Bound(1, inc=True, is_lower=False) 41 | 42 | assert _Bound(1, inc=False, is_lower=True) > _Bound(1, inc=True, is_lower=False) 43 | 44 | assert _Bound(1, inc=True, is_lower=False) < _Bound(1, inc=False, is_lower=True) 45 | 46 | assert _Bound(1, inc=True, is_lower=True) < _Bound(1, inc=True, is_lower=False) 47 | assert _Bound(1, inc=True, is_lower=True) < _Bound(1, inc=False, is_lower=True) 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "range_type, lower, upper, lower_inc, upper_inc, exc_type", 52 | [ 53 | (floatrange, 1, None, None, None, TypeError), 54 | (floatrange, None, 10, None, None, TypeError), 55 | (floatrange, 10.0, 1.0, None, None, ValueError), 56 | (floatrange, None, 10.0, True, None, ValueError), 57 | (floatrange, 10.0, None, None, True, ValueError), 58 | (intrange, 1.0, None, None, None, TypeError), 59 | (intrange, None, 10.0, None, None, TypeError), 60 | (intrange, 10, 1, None, None, ValueError), 61 | (intrange, None, 1, True, None, ValueError), 62 | ], 63 | ) 64 | def test_invalid_construction(range_type, lower, upper, lower_inc, upper_inc, exc_type): 65 | with pytest.raises(exc_type): 66 | range_type(lower, upper, lower_inc, upper_inc) 67 | 68 | 69 | @pytest.mark.parametrize( 70 | "range_type, lower, upper", 71 | [ 72 | (floatrange, 1.0, 10.0), 73 | (intrange, 1, 10), 74 | ], 75 | ) 76 | def test_default_bounds(range_type, lower, upper): 77 | inf_range = range_type() 78 | assert not inf_range.lower_inc 79 | assert not inf_range.upper_inc 80 | 81 | bounded_range = range_type(lower, upper) 82 | assert bounded_range.lower_inc 83 | assert not bounded_range.upper_inc 84 | 85 | rebound_range = bounded_range.replace(lower, upper) 86 | assert rebound_range.lower_inc 87 | assert not rebound_range.upper_inc 88 | 89 | 90 | def test_replace(): 91 | span = floatrange(1.0, 10.0) 92 | assert span.lower_inc 93 | assert not span.upper_inc 94 | 95 | unbounded_span = span.replace(None) 96 | assert unbounded_span.lower_inf 97 | assert not unbounded_span.lower_inc 98 | assert not unbounded_span.upper_inf 99 | assert not unbounded_span.upper_inc 100 | 101 | # It's a bit confusing that the replace doesn't remember that the range 102 | # used to be lower_inc. However, we don't have a way of telling that the 103 | # value has not been user specified 104 | rebounded_span = unbounded_span.replace(lower=1.0) 105 | assert not rebounded_span.lower_inc 106 | 107 | 108 | def test_unbounded(): 109 | range = intrange() 110 | 111 | assert range.lower is None 112 | assert range.upper is None 113 | 114 | assert range.lower_inf 115 | assert range.upper_inf 116 | 117 | 118 | @pytest.mark.parametrize( 119 | "value, rep", 120 | [ 121 | (floatrange(), "floatrange()"), 122 | (floatrange.empty(), "floatrange.empty()"), 123 | (floatrange(1.0), "floatrange(1.0)"), 124 | (floatrange(1.0, 10.0), "floatrange(1.0, 10.0)"), 125 | (floatrange(None, 1.0), "floatrange(upper=1.0)"), 126 | (floatrange(1.0, lower_inc=False), "floatrange(1.0, lower_inc=False)"), 127 | ( 128 | floatrange(upper=10.0, upper_inc=True), 129 | "floatrange(upper=10.0, upper_inc=True)", 130 | ), 131 | (intrange(1, 10), "intrange(1, 10)"), 132 | ], 133 | ) 134 | def test_repr(value, rep): 135 | assert repr(value) == rep 136 | 137 | 138 | def test_immutable(): 139 | range = intrange() 140 | 141 | with pytest.raises(AttributeError): 142 | range.lower = 1 143 | 144 | with pytest.raises(AttributeError): 145 | range.upper = 10 146 | 147 | with pytest.raises(AttributeError): 148 | range.lower_inc = True 149 | 150 | with pytest.raises(AttributeError): 151 | range.upper_inc = True 152 | 153 | 154 | def test_last(): 155 | assert intrange().last is None 156 | assert intrange.empty().last is None 157 | assert intrange(1).last is None 158 | 159 | assert intrange(upper=10).last == 9 160 | assert intrange(1, 10).last == 9 161 | 162 | 163 | def test_offset(): 164 | low_range = intrange(0, 5) 165 | high_range = intrange(5, 10) 166 | 167 | assert low_range != high_range 168 | assert low_range.offset(5) == high_range 169 | assert low_range == high_range.offset(-5) 170 | 171 | with pytest.raises(TypeError): 172 | low_range.offset(5.0) 173 | 174 | 175 | def test_offset_unbounded(): 176 | range = intrange() 177 | assert range == range.offset(10) 178 | 179 | assert intrange(1).offset(9) == intrange(10) 180 | assert intrange(upper=1).offset(9) == intrange(upper=10) 181 | 182 | 183 | def test_equality(): 184 | assert intrange(1, 5) == intrange(1, 5) 185 | assert intrange.empty() == intrange.empty() 186 | assert intrange(1, 5) != intrange(1, 5, upper_inc=True) 187 | assert floatrange() == floatrange() 188 | assert not intrange() == None 189 | 190 | 191 | @pytest.mark.parametrize( 192 | "a, b", 193 | [ 194 | (floatrange(1.0, 5.0), floatrange(2.0, 5.0)), 195 | (floatrange(1.0, 4.0), floatrange(1.0, 5.0)), 196 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0, upper_inc=True)), 197 | (floatrange(1.0, 5.0), floatrange(1.0)), 198 | (floatrange(upper=5.0), floatrange(1.0, 5.0)), 199 | ], 200 | ) 201 | def test_less_than(a, b): 202 | assert a < b 203 | assert not b < a 204 | 205 | 206 | @pytest.mark.parametrize( 207 | "a, b", 208 | [ 209 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0)), 210 | (floatrange(1.0, 4.0), floatrange(1.0, 5.0)), 211 | ], 212 | ) 213 | def test_less_equal(a, b): 214 | assert a <= b 215 | 216 | 217 | @pytest.mark.parametrize( 218 | "a, b", 219 | [ 220 | (floatrange.empty(), floatrange.empty()), 221 | (floatrange.empty(), floatrange(1.0)), 222 | (floatrange(upper=-1.0), floatrange.empty()), 223 | ], 224 | ) 225 | def test_empty_comparison(a, b): 226 | assert not a < b 227 | assert not a > b 228 | 229 | 230 | @pytest.mark.parametrize( 231 | "a, b", 232 | [ 233 | (intrange(), floatrange()), 234 | (intrange(), None), 235 | ], 236 | ) 237 | @pytest.mark.parametrize( 238 | "op", 239 | [ 240 | operator.lt, 241 | operator.le, 242 | operator.gt, 243 | operator.ge, 244 | ], 245 | ) 246 | def test_comparison_operator_type_checks(a, b, op): 247 | with pytest.raises(TypeError): 248 | op(a, b) 249 | 250 | 251 | def test_greater_than(): 252 | assert intrange(2, 5) > intrange(1, 5) 253 | assert intrange(1, 5) > intrange(1, 4) 254 | assert not intrange(1, 5) > intrange(1, 5) 255 | assert intrange(1, 5, upper_inc=True) > intrange(1, 5) 256 | assert intrange(1, 5, lower_inc=False) > intrange(1, 5) 257 | assert intrange(2) > intrange(1, 5) 258 | 259 | assert intrange(1, 5) >= intrange(1, 5) 260 | assert intrange(1, 5) >= intrange(1, 4) 261 | assert not intrange(1, 5) >= intrange(2, 5) 262 | 263 | with pytest.raises(TypeError): 264 | intrange() > floatrange() 265 | 266 | with pytest.raises(TypeError): 267 | intrange() >= floatrange() 268 | 269 | 270 | @pytest.mark.parametrize( 271 | "a, b", 272 | [ 273 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0)), 274 | (floatrange(1.0, 5.0, lower_inc=False), floatrange(5.0, 10.0, upper_inc=True)), 275 | ], 276 | ) 277 | def test_left_or_right_of(a, b): 278 | assert a.left_of(b) 279 | assert a << b 280 | assert b.right_of(a) 281 | assert b >> a 282 | 283 | 284 | @pytest.mark.parametrize( 285 | "a, b", 286 | [ 287 | (floatrange(5.0, 10.0), floatrange(1.0, 5.0)), 288 | (floatrange(1.0, 5.0, upper_inc=True), floatrange(5.0, 10.0)), 289 | (floatrange.empty(), floatrange.empty()), 290 | ], 291 | ) 292 | def test_not_left_or_right_of(a, b): 293 | assert not a.left_of(b) 294 | assert not (a << b) 295 | assert not b.right_of(a) 296 | assert not (b >> a) 297 | 298 | 299 | def test_left_of_type_check(): 300 | with pytest.raises(TypeError): 301 | floatrange().left_of(None) 302 | with pytest.raises(TypeError): 303 | assert floatrange() << None 304 | 305 | 306 | def test_right_of_type_check(): 307 | with pytest.raises(TypeError): 308 | floatrange().right_of(None) 309 | with pytest.raises(TypeError): 310 | floatrange() >> None 311 | 312 | 313 | @pytest.mark.parametrize( 314 | "a, b", 315 | [ 316 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0)), 317 | (floatrange(1.0, 5.0), floatrange(1.0, 10.0)), 318 | (floatrange(5.0, 10.0), floatrange(5.0, 10.0)), 319 | (floatrange(1.0, 10.0), floatrange(upper=5.0)), 320 | (floatrange(1.0, 5.0), 0.0), 321 | ], 322 | ) 323 | def test_startsafter(a, b): 324 | assert a.startsafter(b) 325 | 326 | 327 | @pytest.mark.parametrize( 328 | "a, b", 329 | [ 330 | (floatrange.empty(), floatrange(1.0, 5.0)), 331 | (floatrange(1.0, 5.0), floatrange.empty()), 332 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0)), 333 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0, lower_inc=False)), 334 | (floatrange(1.0, 10.0), floatrange(5.0)), 335 | (floatrange.empty(), 1.0), 336 | ], 337 | ) 338 | def test_not_startsafter(a, b): 339 | assert not a.startsafter(b) 340 | 341 | 342 | def test_startsafter_type_check(): 343 | with pytest.raises(TypeError): 344 | intrange(1, 5).startsafter(1.0) 345 | 346 | 347 | @pytest.mark.parametrize( 348 | "a, b", 349 | [ 350 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0)), 351 | (floatrange(5.0, 10.0), floatrange(1.0, 10.0)), 352 | (floatrange(1.0, 10.0), floatrange(5.0)), 353 | (floatrange(1.0, 5.0), 5.0), 354 | ], 355 | ) 356 | def test_endsbefore(a, b): 357 | assert a.endsbefore(b) 358 | 359 | 360 | @pytest.mark.parametrize( 361 | "a, b", 362 | [ 363 | (floatrange.empty(), floatrange(1.0, 5.0)), 364 | (floatrange(1.0, 5.0), floatrange.empty()), 365 | (floatrange(5.0, 10.0), floatrange(1.0, 5.0)), 366 | (floatrange(5.0, 10.0), floatrange.empty()), 367 | (floatrange(1.0, 5.0, upper_inc=True), floatrange(1.0, 5.0)), 368 | (floatrange(1.0, 10.0), floatrange(upper=5.0)), 369 | (floatrange.empty(), 1.0), 370 | ], 371 | ) 372 | def test_not_endsbefore(a, b): 373 | assert not a.endsbefore(b) 374 | 375 | 376 | def test_endsbefore_type_check(): 377 | with pytest.raises(TypeError): 378 | intrange(1, 5).endsbefore(1.0) 379 | 380 | 381 | @pytest.mark.parametrize( 382 | "a, b", 383 | [ 384 | (intrange(1, 5), intrange(1, 5)), 385 | (intrange(1, 5), intrange(1, 10)), 386 | (intrange(1, 5), 1), 387 | ], 388 | ) 389 | def test_startswith(a, b): 390 | assert a.startswith(b) 391 | 392 | 393 | @pytest.mark.parametrize( 394 | "a, b", 395 | [ 396 | (intrange(1, 5), intrange(5, 10)), 397 | (intrange(5, 10), intrange(1, 5)), 398 | (intrange(1, 5), intrange(1, 5, lower_inc=False)), 399 | (intrange(1, 5), 0), 400 | ], 401 | ) 402 | def test_not_startswith(a, b): 403 | assert not a.startswith(b) 404 | 405 | 406 | def test_startswith_type_check(): 407 | with pytest.raises(TypeError): 408 | intrange(1, 5).startswith(5.0) 409 | 410 | 411 | @pytest.mark.parametrize( 412 | "a, b", 413 | [ 414 | (intrange(5, 10), intrange(5, 10)), 415 | (intrange(1, 10), intrange(5, 10)), 416 | (intrange(1, 5, upper_inc=True), 5), 417 | ], 418 | ) 419 | def test_startswith(a, b): 420 | assert a.endswith(b) 421 | 422 | 423 | @pytest.mark.parametrize( 424 | "a, b", 425 | [ 426 | (intrange(1, 5), intrange(5, 10)), 427 | (intrange(5, 10), intrange(1, 5)), 428 | (intrange(1, 5, upper_inc=True), intrange(1, 5)), 429 | (intrange(1, 5), 5), 430 | ], 431 | ) 432 | def test_not_endswith(a, b): 433 | assert not a.endswith(b) 434 | 435 | 436 | def test_endswith_type_check(): 437 | with pytest.raises(TypeError): 438 | intrange(1, 5).endswith(5.0) 439 | 440 | 441 | @pytest.mark.parametrize( 442 | "a, b", 443 | [ 444 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0)), 445 | (floatrange(1.0, 10.0), floatrange(1.0, 5.0)), 446 | (floatrange(1.0, 10.0), floatrange(5.0, 10.0)), 447 | (floatrange(1.0, 5.0), 1.0), 448 | (floatrange(1.0, 5.0), 3.0), 449 | (floatrange(1.0), 3.0), 450 | (floatrange(upper=5.0), 3.0), 451 | ], 452 | ) 453 | def test_contains(a, b): 454 | assert a.contains(b) 455 | assert b in a 456 | 457 | 458 | @pytest.mark.parametrize( 459 | "a, b", 460 | [ 461 | (floatrange(1.0, 5.0, lower_inc=False), floatrange(1.0, 5.0)), 462 | (floatrange(1.0, 5.0), floatrange(1.0, 5.0, upper_inc=True)), 463 | (floatrange(1.0, 5.0, lower_inc=False), 1.0), 464 | (floatrange(1.0, 5.0), 5.0), 465 | (floatrange(1.0, lower_inc=False), 1.0), 466 | (floatrange(upper=5.0), 5.0), 467 | ], 468 | ) 469 | def test_not_contains(a, b): 470 | assert not a.contains(b) 471 | assert b not in a 472 | 473 | 474 | def test_contains_type_check(): 475 | with pytest.raises(TypeError): 476 | intrange(1, 5).contains(None) 477 | 478 | 479 | @pytest.mark.parametrize( 480 | "a, b", 481 | [ 482 | (intrange(1, 5), intrange(1, 5)), 483 | (intrange(1, 5), intrange(1, 10)), 484 | (intrange(5, 10), intrange(1, 10)), 485 | ], 486 | ) 487 | def test_within(a, b): 488 | assert a.within(b) 489 | 490 | 491 | @pytest.mark.parametrize( 492 | "a, b", 493 | [ 494 | (intrange(1, 5), intrange(1, 5, lower_inc=False)), 495 | (intrange(1, 5, upper_inc=True), intrange(1, 5)), 496 | ], 497 | ) 498 | def test_not_within(a, b): 499 | assert not a.within(b) 500 | 501 | 502 | @pytest.mark.parametrize( 503 | "value", 504 | [ 505 | True, 506 | None, 507 | 1, 508 | ], 509 | ) 510 | def test_within_type_check(value): 511 | with pytest.raises(TypeError): 512 | intrange(1, 5).within(value) 513 | 514 | 515 | @pytest.mark.parametrize( 516 | "a, b", 517 | [ 518 | (floatrange(1.0, 5.0, upper_inc=True), floatrange(5.0, 10.0)), 519 | (floatrange(1.0, 5.0), floatrange(3.0, 8.0)), 520 | (floatrange(1.0, 10.0), floatrange(2.0, 8.0)), 521 | (floatrange(1.0, 10.0), floatrange(5.0)), 522 | (floatrange(upper=10.0), floatrange(1.0, 5.0)), 523 | (floatrange(1.0), floatrange()), 524 | ], 525 | ) 526 | def test_overlap(a, b): 527 | assert a.overlap(b) 528 | assert b.overlap(a) 529 | 530 | 531 | @pytest.mark.parametrize( 532 | "a, b", 533 | [ 534 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0)), 535 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0, lower_inc=False)), 536 | (floatrange(upper=5.0), floatrange(5.0)), 537 | (floatrange(1.0, 5.0, upper_inc=True), floatrange(5.0, 10.0, lower_inc=False)), 538 | ], 539 | ) 540 | def test_not_overlap(a, b): 541 | assert not a.overlap(b) 542 | assert not b.overlap(a) 543 | 544 | 545 | @pytest.mark.parametrize( 546 | "a, b", 547 | [ 548 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0)), 549 | ], 550 | ) 551 | def test_adjacent(a, b): 552 | assert a.adjacent(b) 553 | assert b.adjacent(a) 554 | 555 | 556 | @pytest.mark.parametrize( 557 | "a, b", 558 | [ 559 | (floatrange(1.0, 5.0, upper_inc=True), floatrange(5.0, 10.0)), 560 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0, lower_inc=False)), 561 | (floatrange(1.0, 5.0), floatrange(3.0, 8.0)), 562 | (floatrange(3.0, 8.0), floatrange(1.0, 5.0)), 563 | (floatrange.empty(), floatrange(0.0, 5.0)), 564 | ], 565 | ) 566 | def test_not_adjacent(a, b): 567 | assert not a.adjacent(b) 568 | assert not b.adjacent(a) 569 | 570 | 571 | @pytest.mark.parametrize( 572 | "value", 573 | [ 574 | None, 575 | 1, 576 | floatrange(5.0, 10.0), 577 | ], 578 | ) 579 | def test_adjacent_type_check(value): 580 | with pytest.raises(TypeError): 581 | intrange(1, 5).adjacent(value) 582 | 583 | 584 | @pytest.mark.parametrize( 585 | "a, b, union", 586 | [ 587 | (floatrange.empty(), floatrange(5.0, 10.0), floatrange(5.0, 10.0)), 588 | (floatrange(1.0, 5.0), floatrange.empty(), floatrange(1.0, 5.0)), 589 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0), floatrange(1.0, 10.0)), 590 | (floatrange(1.0, 10.0), floatrange(5.0, 15.0), floatrange(1.0, 15.0)), 591 | ( 592 | floatrange(1.0, 10.0, lower_inc=False), 593 | floatrange(5.0, 15.0), 594 | floatrange(1.0, 15.0, lower_inc=False), 595 | ), 596 | ( 597 | floatrange(1.0, 10.0), 598 | floatrange(5.0, 15.0, upper_inc=True), 599 | floatrange(1.0, 15.0, upper_inc=True), 600 | ), 601 | (floatrange(10.0, 15.0), floatrange(1.0, 25.0), floatrange(1.0, 25.0)), 602 | (floatrange(0.0), floatrange(upper=10.0), floatrange()), 603 | ], 604 | ) 605 | def test_union(a, b, union): 606 | assert a.union(b) == union 607 | assert b.union(a) == union 608 | assert a | b == union 609 | assert b | a == union 610 | 611 | 612 | @pytest.mark.parametrize( 613 | "a, b", 614 | [ 615 | (floatrange(1.0, 5.0), floatrange(5.0, 10.0, lower_inc=False)), 616 | (floatrange(5.0, 10.0, lower_inc=False), floatrange(1.0, 5.0)), 617 | (floatrange(1.0, 5.0), floatrange(10.0, 15.0)), 618 | ], 619 | ) 620 | def test_broken_union(a, b): 621 | with pytest.raises(ValueError): 622 | a.union(b) 623 | 624 | with pytest.raises(ValueError): 625 | b.union(a) 626 | 627 | with pytest.raises(ValueError): 628 | a | b 629 | 630 | with pytest.raises(ValueError): 631 | b | a 632 | 633 | 634 | def test_union_typecheck(): 635 | a = floatrange(1.0, 5.0) 636 | b = intrange(5, 10) 637 | 638 | with pytest.raises(TypeError): 639 | a.union(b) 640 | with pytest.raises(TypeError): 641 | a | b 642 | 643 | 644 | @pytest.mark.parametrize( 645 | "a, b, difference", 646 | [ 647 | (intrange(1, 5), intrange.empty(), intrange(1, 5)), 648 | (intrange(1, 5), intrange(5, 10), intrange(1, 5)), 649 | (intrange(1, 5), intrange(3, 8), intrange(1, 3)), 650 | (intrange(3, 8), intrange(1, 5), intrange(5, 8)), 651 | ( 652 | intrange(3, 8), 653 | intrange(1, 5, upper_inc=True), 654 | intrange(5, 8, lower_inc=False), 655 | ), 656 | (intrange(1, 5), intrange(1, 3), intrange(3, 5)), 657 | (intrange(1, 5), intrange(3, 5), intrange(1, 3)), 658 | (intrange(1, 5), intrange(1, 10), intrange.empty()), 659 | ], 660 | ) 661 | def test_difference(a, b, difference): 662 | assert a.difference(b) == difference 663 | assert a - b == difference 664 | 665 | 666 | def test_broken_difference(): 667 | with pytest.raises(ValueError): 668 | intrange(1, 15).difference(intrange(5, 10)) 669 | 670 | with pytest.raises(ValueError): 671 | intrange(1, 15) - intrange(5, 10) 672 | 673 | 674 | def test_difference_typecheck(): 675 | a = floatrange(1.0, 10.0) 676 | b = intrange(5, 10) 677 | 678 | with pytest.raises(TypeError): 679 | a.difference(b) 680 | with pytest.raises(TypeError): 681 | a - b 682 | 683 | 684 | @pytest.mark.parametrize( 685 | "a, b, intersection", 686 | [ 687 | (intrange(1, 5), intrange(1, 5), intrange(1, 5)), 688 | (intrange(1, 15), intrange(5, 10), intrange(5, 10)), 689 | (intrange(1, 5), intrange(3, 8), intrange(3, 5)), 690 | (intrange(3, 8), intrange(1, 5), intrange(3, 5)), 691 | ( 692 | intrange(3, 8, lower_inc=False), 693 | intrange(1, 5), 694 | intrange(3, 5, lower_inc=False), 695 | ), 696 | (intrange(1, 10), intrange(5), intrange(5, 10)), 697 | (intrange(1, 10), intrange(upper=5), intrange(1, 5)), 698 | ], 699 | ) 700 | def test_intersection(a, b, intersection): 701 | assert a.intersection(b) == intersection 702 | assert b.intersection(a) == intersection 703 | assert a & b == intersection 704 | assert b & a == intersection 705 | 706 | 707 | def test_intersection_typecheck(): 708 | a = floatrange(1.0, 5.0) 709 | b = intrange(5, 10) 710 | 711 | with pytest.raises(TypeError): 712 | a.intersection(b) 713 | with pytest.raises(TypeError): 714 | a & b 715 | 716 | 717 | def test_pickling(): 718 | span = intrange(1, 10) 719 | assert span == pickle.loads(pickle.dumps(span)) 720 | 721 | 722 | def test_bug7_overlap_empty(): 723 | assert not intrange(1, 10).overlap(intrange.empty()) 724 | 725 | 726 | @pytest.mark.parametrize( 727 | "cls", 728 | [ 729 | daterange, 730 | datetimerange, 731 | intrange, 732 | floatrange, 733 | PeriodRange, 734 | strrange, 735 | timedeltarange, 736 | ], 737 | ) 738 | def test_bug10_missing_slots_in_cls_hierarchy(cls): 739 | """ 740 | `Bug #10 `_ 741 | """ 742 | 743 | for c in cls.mro(): 744 | if c is object: 745 | continue 746 | assert hasattr(c, "__slots__") 747 | 748 | 749 | def test_bug11_valid_union_call_detected_as_invalid(): 750 | """ 751 | `Bug #11 `_ 752 | """ 753 | start, middle, end = 0.0, 1.0, 2.0 754 | a = floatrange(start, middle, upper_inc=True) 755 | b = floatrange(middle, end) 756 | 757 | assert a.union(b) == floatrange(start, end) 758 | 759 | 760 | @pytest.mark.parametrize( 761 | "value, empty", 762 | [ 763 | (floatrange(5.0, 5.0, lower_inc=True, upper_inc=True), False), 764 | (floatrange(5.0, 5.0, lower_inc=True, upper_inc=False), True), 765 | (floatrange(5.0, 5.0, lower_inc=True, upper_inc=False), True), 766 | (floatrange(5.0, 5.0, lower_inc=False, upper_inc=True), True), 767 | ( 768 | floatrange(0.0, 5.0, upper_inc=True).intersection( 769 | floatrange(5.0, lower_inc=False) 770 | ), 771 | True, 772 | ), 773 | ], 774 | ) 775 | def test_bug18_range_not_normalized_to_empty(value, empty): 776 | """ 777 | `Bug #18 `_ 778 | """ 779 | assert bool(value) is not empty 780 | -------------------------------------------------------------------------------- /spans/settypes.py: -------------------------------------------------------------------------------- 1 | # Imports needed for doctests in date range sets 2 | from datetime import * 3 | from itertools import chain 4 | 5 | from ._utils import PartialOrderingMixin 6 | from .types import * 7 | from .types import DiscreteRange, OffsetableRangeMixin, Range 8 | 9 | __all__ = [ 10 | "intrangeset", 11 | "floatrangeset", 12 | "strrangeset", 13 | "daterangeset", 14 | "datetimerangeset", 15 | "timedeltarangeset", 16 | ] 17 | 18 | 19 | class MetaRangeSet(type): 20 | """ 21 | A meta class for RangeSets. The purpose is to automatically add relevant 22 | mixins to the range set class based on what mixins and base classes the 23 | range class has. 24 | 25 | All subclasses of :class:`~spans.settypes.RangeSet` uses this class as its 26 | metaclass 27 | 28 | .. versionchanged:: 0.5.0 29 | Changed name from ``metarangeset`` to ``MetaRangeSet`` 30 | """ 31 | 32 | mixin_map = {} 33 | 34 | def __new__(cls, name, bases, attrs): 35 | parents = list(bases) 36 | 37 | if "type" in attrs: 38 | for rangemixin, RangeSetmixin in cls.mixin_map.items(): 39 | if issubclass(attrs["type"], rangemixin): 40 | parents.append(RangeSetmixin) 41 | 42 | return super(MetaRangeSet, cls).__new__(cls, name, tuple(parents), attrs) 43 | 44 | @classmethod 45 | def add(cls, range_mixin, range_set_mixin): 46 | """ 47 | Register a range set mixin for a range mixin. 48 | 49 | :param range_mixin: Range mixin class 50 | :param range_set_mixin: Range set mixin class 51 | """ 52 | 53 | cls.mixin_map[range_mixin] = range_set_mixin 54 | 55 | @classmethod 56 | def register(cls, range_mixin): 57 | """ 58 | Decorator for registering range set mixins for global use. This works 59 | the same as :meth:`~spans.settypes.MetaRangeSet.add` 60 | 61 | :param range_mixin: A :class:`~spans.types.Range` mixin class to 62 | to register a decorated range set mixin class for 63 | :return: A decorator to use on a range set mixin class 64 | """ 65 | 66 | def decorator(range_set_mixin): 67 | cls.add(range_mixin, range_set_mixin) 68 | return range_set_mixin 69 | 70 | return decorator 71 | 72 | 73 | @MetaRangeSet.register(DiscreteRange) 74 | class DiscreteRangeSetMixin(object): 75 | """ 76 | Mixin that adds support for discrete range set operations. Automatically used 77 | by :class:`~spans.settypes.RangeSet` when :class:`~spans.types.Range` type 78 | inherits :class:`~spans.types.DiscreteRange`. 79 | 80 | .. versionchanged:: 0.5.0 81 | Changed name from ``discreterangeset`` to ``DiscreteRangeSetMixin`` 82 | """ 83 | 84 | __slots__ = () 85 | 86 | def values(self): 87 | """ 88 | Returns an iterator over each value in this range set. 89 | 90 | >>> list(intrangeset([intrange(1, 5), intrange(10, 15)]).values()) 91 | [1, 2, 3, 4, 10, 11, 12, 13, 14] 92 | 93 | """ 94 | 95 | return chain(*self) 96 | 97 | 98 | @MetaRangeSet.register(OffsetableRangeMixin) 99 | class OffsetableRangeSetMixin(object): 100 | """ 101 | Mixin that adds support for offsetable range set operations. Automatically 102 | used by :class:`~spans.settypes.RangeSet` when range type inherits 103 | :class:`~spans.settypes.OffsetableRangeMixin`. 104 | 105 | .. versionchanged:: 0.5.0 106 | Changed name from ``offsetablerangeset`` to ``OffsetableRangeSetMixin`` 107 | """ 108 | 109 | __slots__ = () 110 | 111 | def offset(self, offset): 112 | """ 113 | Shift the range set to the left or right with the given offset 114 | 115 | >>> intrangeset([intrange(0, 5), intrange(10, 15)]).offset(5) 116 | intrangeset([intrange(5, 10), intrange(15, 20)]) 117 | >>> intrangeset([intrange(5, 10), intrange(15, 20)]).offset(-5) 118 | intrangeset([intrange(0, 5), intrange(10, 15)]) 119 | 120 | This function returns an offset copy of the original set, i.e. updating 121 | is not done in place. 122 | """ 123 | 124 | return self.__class__(r.offset(offset) for r in self) 125 | 126 | 127 | class RangeSet(PartialOrderingMixin, metaclass=MetaRangeSet): 128 | """ 129 | A range set works a lot like a range with some differences: 130 | 131 | - All range sets supports ``len()``. Cardinality for a range set means the 132 | number of distinct ranges required to represent this set. See 133 | :meth:`~spans.settypes.RangeSet.__len__`. 134 | - All range sets are iterable. The iterator returns a range for each 135 | iteration. See :meth:`~spans.settypes.RangeSet.__iter__` for more details. 136 | - All range sets are invertible using the ``~`` operator. The result is a 137 | new range set that does not intersect the original range set at all. 138 | 139 | >>> ~intrangeset([intrange(1, 5)]) 140 | intrangeset([intrange(upper=1), intrange(5)]) 141 | 142 | - Contrary to ranges. A range set may be split into multiple ranges when 143 | performing set operations such as union, difference or intersection. 144 | 145 | .. tip:: 146 | The ``RangeSet`` constructor supports any iterable sequence as argument. 147 | 148 | :param ranges: A sequence of ranges to add to this set. 149 | :raises TypeError: If any of the given ranges are of incorrect type. 150 | 151 | .. versionchanged:: 0.5.0 152 | Changed name from ``rangeset`` to ``RangeSet`` 153 | """ 154 | 155 | __slots__ = ("_list",) 156 | 157 | def __init__(self, ranges): 158 | self._list = [] 159 | 160 | for r in ranges: 161 | self.add(r) 162 | 163 | def __repr__(self): 164 | return f"{self.__class__.__name__}({self._list!r})" 165 | 166 | # Support pickling using the default ancient pickling protocol for Python 2.7 167 | def __getstate__(self): 168 | # We wrap the list in a tuple to prevent it from being falsy as that 169 | # causes Python to not use our value when deserializing 170 | return (self._list,) 171 | 172 | def __setstate__(self, state): 173 | # Since __getstate__ used to return a list we allow allow loading data 174 | # serialized by an older version of spans 175 | if isinstance(state, tuple): 176 | self._list = state[0] 177 | else: 178 | self._list = state 179 | 180 | def __bool__(self): 181 | """ 182 | Returns False if the only thing in this set is the empty set, otherwise 183 | it returns True. 184 | 185 | >>> bool(intrangeset([])) 186 | False 187 | >>> bool(intrangeset([intrange(1, 5)])) 188 | True 189 | 190 | """ 191 | return bool(self._list) 192 | 193 | def __iter__(self): 194 | """ 195 | Returns an iterator over all ranges within this set. Note that this 196 | iterates over the normalized version of the range set: 197 | 198 | >>> list(intrangeset( 199 | ... [intrange(1, 5), intrange(5, 10), intrange(15, 20)])) 200 | [intrange(1, 10), intrange(15, 20)] 201 | 202 | If the set is empty an empty iterator is returned. 203 | 204 | >>> list(intrangeset([])) 205 | [] 206 | 207 | .. versionchanged:: 0.3.0 208 | This method used to return an empty range when the RangeSet was 209 | empty. 210 | """ 211 | 212 | return iter(self._list) 213 | 214 | def __eq__(self, other): 215 | if not isinstance(other, self.__class__): 216 | return NotImplemented 217 | return self._list == other._list 218 | 219 | def __lt__(self, other): 220 | if not isinstance(other, self.__class__): 221 | return NotImplemented 222 | return self._list < other._list 223 | 224 | def __len__(self): 225 | """ 226 | Returns the cardinality of the set which is 0 for the empty set or else 227 | the number of ranges used to represent this range set. 228 | 229 | >>> len(intrangeset([])) 230 | 0 231 | >>> len(intrangeset([intrange(1,5)])) 232 | 1 233 | >>> len(intrangeset([intrange(1,5),intrange(10,20)])) 234 | 2 235 | 236 | 237 | .. versionadded:: 0.2.0 238 | """ 239 | return len(self._list) 240 | 241 | def __invert__(self): 242 | """ 243 | Returns an inverted version of this set. The inverted set contains no 244 | values this contains. 245 | 246 | >>> ~intrangeset([intrange(1, 5)]) 247 | intrangeset([intrange(upper=1), intrange(5)]) 248 | 249 | """ 250 | 251 | return self.__class__([self.type()]).difference(self) 252 | 253 | @classmethod 254 | def is_valid_rangeset(cls, obj): 255 | return isinstance(obj, cls) 256 | 257 | @classmethod 258 | def is_valid_range(cls, obj): 259 | return cls.type.is_valid_range(obj) 260 | 261 | @classmethod 262 | def is_valid_scalar(cls, obj): 263 | return cls.type.is_valid_scalar(obj) 264 | 265 | def _test_rangeset_type(self, item): 266 | if not self.is_valid_rangeset(item): 267 | raise TypeError( 268 | f"Invalid range type {item.__class__.__name__!r} expected {self.type.__name__!r}" 269 | ) 270 | 271 | def _test_range_type(self, item): 272 | if not self.is_valid_range(item): 273 | raise TypeError( 274 | f"Invalid range type {item.__class__.__name__!r} expected {self.type.__name__!r}" 275 | ) 276 | 277 | def copy(self): 278 | """ 279 | Makes a copy of this set. This copy is not deep since ranges are 280 | immutable. 281 | 282 | >>> rs = intrangeset([intrange(1, 5)]) 283 | >>> rs_copy = rs.copy() 284 | >>> rs == rs_copy 285 | True 286 | >>> rs is rs_copy 287 | False 288 | 289 | :return: A new range set with the same ranges as this range set. 290 | """ 291 | 292 | return self.__class__(self) 293 | 294 | def contains(self, item): 295 | """ 296 | Test if this range 297 | Return True if one range within the set contains elem, which may be 298 | either a range of the same type or a scalar of the same type as the 299 | ranges within the set. 300 | 301 | >>> intrangeset([intrange(1, 5)]).contains(3) 302 | True 303 | >>> intrangeset([intrange(1, 5), intrange(10, 20)]).contains(7) 304 | False 305 | >>> intrangeset([intrange(1, 5)]).contains(intrange(2, 3)) 306 | True 307 | >>> intrangeset( 308 | ... [intrange(1, 5), intrange(8, 9)]).contains(intrange(4, 6)) 309 | False 310 | 311 | Contains can also be called using the ``in`` operator. 312 | 313 | >>> 3 in intrangeset([intrange(1, 5)]) 314 | True 315 | 316 | This operation is `O(n)` where `n` is the number of ranges within this 317 | range set. 318 | 319 | :param item: Range or scalar to test for. 320 | :return: True if element is contained within this set. 321 | 322 | .. versionadded:: 0.2.0 323 | """ 324 | 325 | # Verify the type here since contains does not validate the type unless 326 | # there are items in self._list 327 | if not self.is_valid_range(item) and not self.is_valid_scalar(item): 328 | msg = "Unsupported item type provided '{}'" 329 | raise ValueError(msg.format(item.__class__.__name__)) 330 | 331 | # All range sets contain the empty range 332 | if not item: 333 | return True 334 | 335 | return any(r.contains(item) for r in self._list) 336 | 337 | def add(self, item): 338 | """ 339 | Adds a range to the set. 340 | 341 | >>> rs = intrangeset([]) 342 | >>> rs.add(intrange(1, 10)) 343 | >>> rs 344 | intrangeset([intrange(1, 10)]) 345 | >>> rs.add(intrange(5, 15)) 346 | >>> rs 347 | intrangeset([intrange(1, 15)]) 348 | >>> rs.add(intrange(20, 30)) 349 | >>> rs 350 | intrangeset([intrange(1, 15), intrange(20, 30)]) 351 | 352 | This operation updates the set in place. 353 | 354 | :param item: Range to add to this set. 355 | :raises TypeError: If any of the given ranges are of incorrect type. 356 | """ 357 | 358 | self._test_range_type(item) 359 | 360 | # If item is empty, do not add it 361 | if not item: 362 | return 363 | 364 | i = 0 365 | buffer = [] 366 | while i < len(self._list): 367 | r = self._list[i] 368 | 369 | if r.overlap(item) or r.adjacent(item): 370 | buffer.append(self._list.pop(i)) 371 | continue 372 | elif item.left_of(r): 373 | # If there are buffered items we must break here for the buffer 374 | # to be inserted 375 | if not buffer: 376 | self._list.insert(i, item) 377 | break 378 | i += 1 379 | else: 380 | # The list was exausted and the range should be appended unless there 381 | # are ranges in the buffer 382 | if not buffer: 383 | self._list.append(item) 384 | 385 | # Process the buffer 386 | if buffer: 387 | # Unify the buffer 388 | for r in buffer: 389 | item = item.union(r) 390 | self.add(item) 391 | 392 | def remove(self, item): 393 | """ 394 | Remove a range from the set. This operation updates the set in place. 395 | 396 | >>> rs = intrangeset([intrange(1, 15)]) 397 | >>> rs.remove(intrange(5, 10)) 398 | >>> rs 399 | intrangeset([intrange(1, 5), intrange(10, 15)]) 400 | 401 | :param item: Range to remove from this set. 402 | """ 403 | 404 | self._test_range_type(item) 405 | 406 | # If the list currently only have an empty range do nothing since an 407 | # empty RangeSet can't be removed from anyway. 408 | if not self: 409 | return 410 | 411 | i = 0 412 | while i < len(self._list): 413 | r = self._list[i] 414 | if item.left_of(r): 415 | break 416 | elif item.overlap(r): 417 | try: 418 | self._list[i] = r.difference(item) 419 | 420 | # If the element becomes empty remove it entirely 421 | if not self._list[i]: 422 | del self._list[i] 423 | continue 424 | except ValueError: 425 | # The range was within the range, causing it to be split so 426 | # we do this split manually 427 | del self._list[i] 428 | self._list.insert( 429 | i, 430 | r.replace(lower=item.upper, lower_inc=not item.upper_inc), 431 | ) 432 | self._list.insert( 433 | i, 434 | r.replace(upper=item.lower, upper_inc=not item.lower_inc), 435 | ) 436 | 437 | # When this happens we know we are done 438 | break 439 | i += 1 440 | 441 | def span(self): 442 | """ 443 | Return a range that spans from the first point to the last point in this 444 | set. This means the smallest range containing all elements of this set 445 | with no gaps. 446 | 447 | >>> intrangeset([intrange(1, 5), intrange(30, 40)]).span() 448 | intrange(1, 40) 449 | 450 | 451 | This method can be used to implement the PostgreSQL function 452 | ``range_merge(a, b)``: 453 | 454 | >>> a = intrange(1, 5) 455 | >>> b = intrange(10, 15) 456 | >>> intrangeset([a, b]).span() 457 | intrange(1, 15) 458 | 459 | :return: A new range the contains this entire range set. 460 | """ 461 | 462 | # If the set is empty we treat it specially by returning an empty range 463 | if not self: 464 | return self.type.empty() 465 | 466 | return self._list[0].replace( 467 | upper=self._list[-1].upper, 468 | upper_inc=self._list[-1].upper_inc, 469 | ) 470 | 471 | def union(self, *others): 472 | """ 473 | Returns this set combined with every given set into a super set for each 474 | given set. 475 | 476 | >>> intrangeset([intrange(1, 5)]).union( 477 | ... intrangeset([intrange(5, 10)])) 478 | intrangeset([intrange(1, 10)]) 479 | 480 | :param other: Range set to merge with. 481 | :return: A new range set that is the union of this and `other`. 482 | """ 483 | 484 | # Make a copy of self and add all its ranges to the copy 485 | union = self.copy() 486 | for other in others: 487 | self._test_rangeset_type(other) 488 | for r in other: 489 | union.add(r) 490 | return union 491 | 492 | def difference(self, *others): 493 | """ 494 | Returns this set stripped of every subset that are in the other given 495 | sets. 496 | 497 | >>> intrangeset([intrange(1, 15)]).difference( 498 | ... intrangeset([intrange(5, 10)])) 499 | intrangeset([intrange(1, 5), intrange(10, 15)]) 500 | 501 | :param other: Range set to compute difference against. 502 | :return: A new range set that is the difference between this and `other`. 503 | """ 504 | 505 | # Make a copy of self and remove all its ranges from the copy 506 | difference = self.copy() 507 | for other in others: 508 | self._test_rangeset_type(other) 509 | for r in other: 510 | difference.remove(r) 511 | return difference 512 | 513 | def intersection(self, *others): 514 | """ 515 | Returns a new set of all subsets that exist in this and every given set. 516 | 517 | >>> intrangeset([intrange(1, 15)]).intersection( 518 | ... intrangeset([intrange(5, 10)])) 519 | intrangeset([intrange(5, 10)]) 520 | 521 | :param other: Range set to intersect this range set with. 522 | :return: A new range set that is the intersection between this and 523 | `other`. 524 | """ 525 | 526 | # Initialize output with a reference to this RangeSet. When 527 | # intersecting against multiple RangeSets at once this will be replaced 528 | # after each iteration. 529 | output = self 530 | 531 | for other in others: 532 | self._test_rangeset_type(other) 533 | 534 | # Intermediate RangeSet containing intersection for this current 535 | # iteration. 536 | intersection = self.__class__([]) 537 | 538 | # Intersect every range within the current output with every range 539 | # within the currently processed other RangeSet. All intersecting 540 | # parts are added to the intermediate intersection set. 541 | for a in output: 542 | for b in other: 543 | intersection.add(a.intersection(b)) 544 | 545 | # If the intermediate intersection RangeSet is still empty, there 546 | # where no intersections with at least one of the arguments and 547 | # we can quit early, since any intersection with the empty set will 548 | # always be empty. 549 | if not intersection: 550 | return intersection 551 | 552 | # Update output with intersection for the current iteration. 553 | output = intersection 554 | 555 | return output 556 | 557 | def __or__(self, other): 558 | try: 559 | return self.union(other) 560 | except TypeError: 561 | return NotImplemented 562 | 563 | def __and__(self, other): 564 | try: 565 | return self.intersection(other) 566 | except TypeError: 567 | return NotImplemented 568 | 569 | def __sub__(self, other): 570 | try: 571 | return self.difference(other) 572 | except TypeError: 573 | return NotImplemented 574 | 575 | # ``in`` operator support 576 | __contains__ = contains 577 | 578 | 579 | class intrangeset(RangeSet): 580 | """ 581 | Range set that operates on :class:`~spans.types.intrange`. 582 | 583 | >>> intrangeset([intrange(1, 5), intrange(10, 15)]) 584 | intrangeset([intrange(1, 5), intrange(10, 15)]) 585 | 586 | Inherits methods from :class:`~spans.settypes.RangeSet`, 587 | :class:`~spans.settypes.DiscreteRangeset` and 588 | :class:`~spans.settypes.OffsetableRangeMixinset`. 589 | """ 590 | 591 | __slots__ = () 592 | 593 | type = intrange 594 | 595 | 596 | class floatrangeset(RangeSet): 597 | """ 598 | Range set that operates on :class:`~spans.types.floatrange`. 599 | 600 | >>> floatrangeset([floatrange(1.0, 5.0), floatrange(10.0, 15.0)]) 601 | floatrangeset([floatrange(1.0, 5.0), floatrange(10.0, 15.0)]) 602 | 603 | Inherits methods from :class:`~spans.settypes.RangeSet`, 604 | :class:`~spans.settypes.DiscreteRangeset` and 605 | :class:`~spans.settypes.OffsetableRangeMixinset`. 606 | """ 607 | 608 | __slots__ = () 609 | 610 | type = floatrange 611 | 612 | 613 | class strrangeset(RangeSet): 614 | """ 615 | Range set that operates on .. seealso:: :class:`~spans.types.strrange`. 616 | 617 | >>> strrangeset([ 618 | ... strrange("a", "f", upper_inc=True), 619 | ... strrange("0", "9", upper_inc=True)]) 620 | strrangeset([strrange('0', ':'), strrange('a', 'g')]) 621 | 622 | Inherits methods from :class:`~spans.settypes.RangeSet` and 623 | :class:`~spans.settypes.DiscreteRangeset`. 624 | """ 625 | 626 | __slots__ = () 627 | 628 | type = strrange 629 | 630 | 631 | class daterangeset(RangeSet): 632 | """ 633 | Range set that operates on :class:`~spans.types.daterange`. 634 | 635 | >>> month = daterange(date(2000, 1, 1), date(2000, 2, 1)) 636 | >>> daterangeset([month, month.offset(timedelta(366))]) # doctest: +NORMALIZE_WHITESPACE 637 | daterangeset([daterange(datetime.date(2000, 1, 1), datetime.date(2000, 2, 1)), 638 | daterange(datetime.date(2001, 1, 1), datetime.date(2001, 2, 1))]) 639 | 640 | Inherits methods from :class:`~spans.settypes.RangeSet`, 641 | :class:`~spans.settypes.DiscreteRangeset` and 642 | :class:`~spans.settypes.OffsetableRangeMixinset`. 643 | """ 644 | 645 | __slots__ = () 646 | 647 | type = daterange 648 | 649 | 650 | class datetimerangeset(RangeSet): 651 | """ 652 | Range set that operates on :class:`~spans.types.datetimerange`. 653 | 654 | >>> month = datetimerange(datetime(2000, 1, 1), datetime(2000, 2, 1)) 655 | >>> datetimerangeset([month, month.offset(timedelta(366))]) # doctest: +NORMALIZE_WHITESPACE 656 | datetimerangeset([datetimerange(datetime.datetime(2000, 1, 1, 0, 0), datetime.datetime(2000, 2, 1, 0, 0)), 657 | datetimerange(datetime.datetime(2001, 1, 1, 0, 0), datetime.datetime(2001, 2, 1, 0, 0))]) 658 | 659 | Inherits methods from :class:`~spans.settypes.RangeSet` and 660 | :class:`~spans.settypes.OffsetableRangeMixinset`. 661 | """ 662 | 663 | __slots__ = () 664 | 665 | type = datetimerange 666 | 667 | 668 | class timedeltarangeset(RangeSet): 669 | """ 670 | Range set that operates on :class:`~spans.types.timedeltarange`. 671 | 672 | >>> week = timedeltarange(timedelta(0), timedelta(7)) 673 | >>> timedeltarangeset([week, week.offset(timedelta(7))]) 674 | timedeltarangeset([timedeltarange(datetime.timedelta(0), datetime.timedelta(days=14))]) 675 | 676 | Inherits methods from :class:`~spans.settypes.RangeSet` and 677 | :class:`~spans.settypes.OffsetableRangeMixinset`. 678 | """ 679 | 680 | __slots__ = () 681 | 682 | type = timedeltarange 683 | 684 | 685 | # Legacy names 686 | 687 | #: This alias exist for legacy reasons. It is considered deprecated but will not 688 | #: likely be removed. 689 | #: 690 | #: .. versionadded:: 0.5.0 691 | metarangeset = MetaRangeSet 692 | 693 | 694 | #: This alias exist for legacy reasons. It is considered deprecated but will not 695 | #: likely be removed. 696 | #: 697 | #: .. versionadded:: 0.5.0 698 | rangeset = RangeSet 699 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "alabaster" 3 | version = "0.7.12" 4 | description = "A configurable sidebar-enabled Sphinx theme" 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.0" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "21.4.0" 20 | description = "Classes Without Boilerplate" 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 27 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 30 | 31 | [[package]] 32 | name = "babel" 33 | version = "2.10.3" 34 | description = "Internationalization utilities" 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.6" 38 | 39 | [package.dependencies] 40 | pytz = ">=2015.7" 41 | 42 | [[package]] 43 | name = "black" 44 | version = "22.3.0" 45 | description = "The uncompromising code formatter." 46 | category = "dev" 47 | optional = false 48 | python-versions = ">=3.6.2" 49 | 50 | [package.dependencies] 51 | click = ">=8.0.0" 52 | mypy-extensions = ">=0.4.3" 53 | pathspec = ">=0.9.0" 54 | platformdirs = ">=2" 55 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 56 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 57 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 58 | 59 | [package.extras] 60 | colorama = ["colorama (>=0.4.3)"] 61 | d = ["aiohttp (>=3.7.4)"] 62 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 63 | uvloop = ["uvloop (>=0.15.2)"] 64 | 65 | [[package]] 66 | name = "certifi" 67 | version = "2022.6.15" 68 | description = "Python package for providing Mozilla's CA Bundle." 69 | category = "dev" 70 | optional = false 71 | python-versions = ">=3.6" 72 | 73 | [[package]] 74 | name = "charset-normalizer" 75 | version = "2.0.12" 76 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 77 | category = "dev" 78 | optional = false 79 | python-versions = ">=3.5.0" 80 | 81 | [package.extras] 82 | unicode_backport = ["unicodedata2"] 83 | 84 | [[package]] 85 | name = "click" 86 | version = "8.1.3" 87 | description = "Composable command line interface toolkit" 88 | category = "dev" 89 | optional = false 90 | python-versions = ">=3.7" 91 | 92 | [package.dependencies] 93 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 94 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 95 | 96 | [[package]] 97 | name = "colorama" 98 | version = "0.4.5" 99 | description = "Cross-platform colored terminal text." 100 | category = "dev" 101 | optional = false 102 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 103 | 104 | [[package]] 105 | name = "docutils" 106 | version = "0.18.1" 107 | description = "Docutils -- Python Documentation Utilities" 108 | category = "dev" 109 | optional = false 110 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 111 | 112 | [[package]] 113 | name = "idna" 114 | version = "3.3" 115 | description = "Internationalized Domain Names in Applications (IDNA)" 116 | category = "dev" 117 | optional = false 118 | python-versions = ">=3.5" 119 | 120 | [[package]] 121 | name = "imagesize" 122 | version = "1.3.0" 123 | description = "Getting image size from png/jpeg/jpeg2000/gif file" 124 | category = "dev" 125 | optional = false 126 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 127 | 128 | [[package]] 129 | name = "importlib-metadata" 130 | version = "4.11.4" 131 | description = "Read metadata from Python packages" 132 | category = "dev" 133 | optional = false 134 | python-versions = ">=3.7" 135 | 136 | [package.dependencies] 137 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 138 | zipp = ">=0.5" 139 | 140 | [package.extras] 141 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 142 | perf = ["ipython"] 143 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 144 | 145 | [[package]] 146 | name = "iniconfig" 147 | version = "1.1.1" 148 | description = "iniconfig: brain-dead simple config-ini parsing" 149 | category = "dev" 150 | optional = false 151 | python-versions = "*" 152 | 153 | [[package]] 154 | name = "isort" 155 | version = "5.10.1" 156 | description = "A Python utility / library to sort Python imports." 157 | category = "dev" 158 | optional = false 159 | python-versions = ">=3.6.1,<4.0" 160 | 161 | [package.extras] 162 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 163 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 164 | colors = ["colorama (>=0.4.3,<0.5.0)"] 165 | plugins = ["setuptools"] 166 | 167 | [[package]] 168 | name = "jinja2" 169 | version = "3.1.2" 170 | description = "A very fast and expressive template engine." 171 | category = "dev" 172 | optional = false 173 | python-versions = ">=3.7" 174 | 175 | [package.dependencies] 176 | MarkupSafe = ">=2.0" 177 | 178 | [package.extras] 179 | i18n = ["Babel (>=2.7)"] 180 | 181 | [[package]] 182 | name = "markupsafe" 183 | version = "2.1.1" 184 | description = "Safely add untrusted strings to HTML/XML markup." 185 | category = "dev" 186 | optional = false 187 | python-versions = ">=3.7" 188 | 189 | [[package]] 190 | name = "mypy-extensions" 191 | version = "0.4.3" 192 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 193 | category = "dev" 194 | optional = false 195 | python-versions = "*" 196 | 197 | [[package]] 198 | name = "packaging" 199 | version = "21.3" 200 | description = "Core utilities for Python packages" 201 | category = "dev" 202 | optional = false 203 | python-versions = ">=3.6" 204 | 205 | [package.dependencies] 206 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 207 | 208 | [[package]] 209 | name = "pathspec" 210 | version = "0.9.0" 211 | description = "Utility library for gitignore style pattern matching of file paths." 212 | category = "dev" 213 | optional = false 214 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 215 | 216 | [[package]] 217 | name = "platformdirs" 218 | version = "2.5.2" 219 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 220 | category = "dev" 221 | optional = false 222 | python-versions = ">=3.7" 223 | 224 | [package.extras] 225 | docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] 226 | test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] 227 | 228 | [[package]] 229 | name = "pluggy" 230 | version = "1.0.0" 231 | description = "plugin and hook calling mechanisms for python" 232 | category = "dev" 233 | optional = false 234 | python-versions = ">=3.6" 235 | 236 | [package.dependencies] 237 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 238 | 239 | [package.extras] 240 | dev = ["pre-commit", "tox"] 241 | testing = ["pytest", "pytest-benchmark"] 242 | 243 | [[package]] 244 | name = "py" 245 | version = "1.11.0" 246 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 247 | category = "dev" 248 | optional = false 249 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 250 | 251 | [[package]] 252 | name = "pygments" 253 | version = "2.12.0" 254 | description = "Pygments is a syntax highlighting package written in Python." 255 | category = "dev" 256 | optional = false 257 | python-versions = ">=3.6" 258 | 259 | [[package]] 260 | name = "pyparsing" 261 | version = "3.0.9" 262 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 263 | category = "dev" 264 | optional = false 265 | python-versions = ">=3.6.8" 266 | 267 | [package.extras] 268 | diagrams = ["railroad-diagrams", "jinja2"] 269 | 270 | [[package]] 271 | name = "pytest" 272 | version = "7.1.2" 273 | description = "pytest: simple powerful testing with Python" 274 | category = "dev" 275 | optional = false 276 | python-versions = ">=3.7" 277 | 278 | [package.dependencies] 279 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 280 | attrs = ">=19.2.0" 281 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 282 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 283 | iniconfig = "*" 284 | packaging = "*" 285 | pluggy = ">=0.12,<2.0" 286 | py = ">=1.8.2" 287 | tomli = ">=1.0.0" 288 | 289 | [package.extras] 290 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 291 | 292 | [[package]] 293 | name = "pytz" 294 | version = "2022.1" 295 | description = "World timezone definitions, modern and historical" 296 | category = "dev" 297 | optional = false 298 | python-versions = "*" 299 | 300 | [[package]] 301 | name = "requests" 302 | version = "2.28.0" 303 | description = "Python HTTP for Humans." 304 | category = "dev" 305 | optional = false 306 | python-versions = ">=3.7, <4" 307 | 308 | [package.dependencies] 309 | certifi = ">=2017.4.17" 310 | charset-normalizer = ">=2.0.0,<2.1.0" 311 | idna = ">=2.5,<4" 312 | urllib3 = ">=1.21.1,<1.27" 313 | 314 | [package.extras] 315 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 316 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 317 | 318 | [[package]] 319 | name = "snowballstemmer" 320 | version = "2.2.0" 321 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 322 | category = "dev" 323 | optional = false 324 | python-versions = "*" 325 | 326 | [[package]] 327 | name = "sphinx" 328 | version = "5.0.2" 329 | description = "Python documentation generator" 330 | category = "dev" 331 | optional = false 332 | python-versions = ">=3.6" 333 | 334 | [package.dependencies] 335 | alabaster = ">=0.7,<0.8" 336 | babel = ">=1.3" 337 | colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} 338 | docutils = ">=0.14,<0.19" 339 | imagesize = "*" 340 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 341 | Jinja2 = ">=2.3" 342 | packaging = "*" 343 | Pygments = ">=2.0" 344 | requests = ">=2.5.0" 345 | snowballstemmer = ">=1.1" 346 | sphinxcontrib-applehelp = "*" 347 | sphinxcontrib-devhelp = "*" 348 | sphinxcontrib-htmlhelp = ">=2.0.0" 349 | sphinxcontrib-jsmath = "*" 350 | sphinxcontrib-qthelp = "*" 351 | sphinxcontrib-serializinghtml = ">=1.1.5" 352 | 353 | [package.extras] 354 | docs = ["sphinxcontrib-websupport"] 355 | lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.950)", "docutils-stubs", "types-typed-ast", "types-requests"] 356 | test = ["pytest (>=4.6)", "html5lib", "cython", "typed-ast"] 357 | 358 | [[package]] 359 | name = "sphinxcontrib-applehelp" 360 | version = "1.0.2" 361 | description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" 362 | category = "dev" 363 | optional = false 364 | python-versions = ">=3.5" 365 | 366 | [package.extras] 367 | lint = ["flake8", "mypy", "docutils-stubs"] 368 | test = ["pytest"] 369 | 370 | [[package]] 371 | name = "sphinxcontrib-devhelp" 372 | version = "1.0.2" 373 | description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." 374 | category = "dev" 375 | optional = false 376 | python-versions = ">=3.5" 377 | 378 | [package.extras] 379 | lint = ["flake8", "mypy", "docutils-stubs"] 380 | test = ["pytest"] 381 | 382 | [[package]] 383 | name = "sphinxcontrib-htmlhelp" 384 | version = "2.0.0" 385 | description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" 386 | category = "dev" 387 | optional = false 388 | python-versions = ">=3.6" 389 | 390 | [package.extras] 391 | lint = ["flake8", "mypy", "docutils-stubs"] 392 | test = ["pytest", "html5lib"] 393 | 394 | [[package]] 395 | name = "sphinxcontrib-jsmath" 396 | version = "1.0.1" 397 | description = "A sphinx extension which renders display math in HTML via JavaScript" 398 | category = "dev" 399 | optional = false 400 | python-versions = ">=3.5" 401 | 402 | [package.extras] 403 | test = ["pytest", "flake8", "mypy"] 404 | 405 | [[package]] 406 | name = "sphinxcontrib-qthelp" 407 | version = "1.0.3" 408 | description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." 409 | category = "dev" 410 | optional = false 411 | python-versions = ">=3.5" 412 | 413 | [package.extras] 414 | lint = ["flake8", "mypy", "docutils-stubs"] 415 | test = ["pytest"] 416 | 417 | [[package]] 418 | name = "sphinxcontrib-serializinghtml" 419 | version = "1.1.5" 420 | description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." 421 | category = "dev" 422 | optional = false 423 | python-versions = ">=3.5" 424 | 425 | [package.extras] 426 | lint = ["flake8", "mypy", "docutils-stubs"] 427 | test = ["pytest"] 428 | 429 | [[package]] 430 | name = "tomli" 431 | version = "2.0.1" 432 | description = "A lil' TOML parser" 433 | category = "dev" 434 | optional = false 435 | python-versions = ">=3.7" 436 | 437 | [[package]] 438 | name = "typed-ast" 439 | version = "1.5.4" 440 | description = "a fork of Python 2 and 3 ast modules with type comment support" 441 | category = "dev" 442 | optional = false 443 | python-versions = ">=3.6" 444 | 445 | [[package]] 446 | name = "typing-extensions" 447 | version = "4.2.0" 448 | description = "Backported and Experimental Type Hints for Python 3.7+" 449 | category = "dev" 450 | optional = false 451 | python-versions = ">=3.7" 452 | 453 | [[package]] 454 | name = "urllib3" 455 | version = "1.26.9" 456 | description = "HTTP library with thread-safe connection pooling, file post, and more." 457 | category = "dev" 458 | optional = false 459 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 460 | 461 | [package.extras] 462 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 463 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 464 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 465 | 466 | [[package]] 467 | name = "zipp" 468 | version = "3.8.0" 469 | description = "Backport of pathlib-compatible object wrapper for zip files" 470 | category = "dev" 471 | optional = false 472 | python-versions = ">=3.7" 473 | 474 | [package.extras] 475 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 476 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 477 | 478 | [metadata] 479 | lock-version = "1.1" 480 | python-versions = "^3.7" # The oldest supported Python version 481 | content-hash = "c4327c2ad7fbce22249a535deabe9abe4a5f5f7872161d226ed19a6a9ab676f8" 482 | 483 | [metadata.files] 484 | alabaster = [ 485 | {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, 486 | {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, 487 | ] 488 | atomicwrites = [ 489 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 490 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 491 | ] 492 | attrs = [ 493 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 494 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 495 | ] 496 | babel = [ 497 | {file = "Babel-2.10.3-py3-none-any.whl", hash = "sha256:ff56f4892c1c4bf0d814575ea23471c230d544203c7748e8c68f0089478d48eb"}, 498 | {file = "Babel-2.10.3.tar.gz", hash = "sha256:7614553711ee97490f732126dc077f8d0ae084ebc6a96e23db1482afabdb2c51"}, 499 | ] 500 | black = [ 501 | {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, 502 | {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, 503 | {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, 504 | {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, 505 | {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, 506 | {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, 507 | {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, 508 | {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, 509 | {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, 510 | {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, 511 | {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, 512 | {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, 513 | {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, 514 | {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, 515 | {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, 516 | {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, 517 | {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, 518 | {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, 519 | {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, 520 | {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, 521 | {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, 522 | {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, 523 | {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, 524 | ] 525 | certifi = [ 526 | {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, 527 | {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, 528 | ] 529 | charset-normalizer = [ 530 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, 531 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, 532 | ] 533 | click = [ 534 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 535 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 536 | ] 537 | colorama = [ 538 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 539 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 540 | ] 541 | docutils = [ 542 | {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, 543 | {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, 544 | ] 545 | idna = [ 546 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 547 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 548 | ] 549 | imagesize = [ 550 | {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, 551 | {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, 552 | ] 553 | importlib-metadata = [ 554 | {file = "importlib_metadata-4.11.4-py3-none-any.whl", hash = "sha256:c58c8eb8a762858f49e18436ff552e83914778e50e9d2f1660535ffb364552ec"}, 555 | {file = "importlib_metadata-4.11.4.tar.gz", hash = "sha256:5d26852efe48c0a32b0509ffbc583fda1a2266545a78d104a6f4aff3db17d700"}, 556 | ] 557 | iniconfig = [ 558 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 559 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 560 | ] 561 | isort = [ 562 | {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, 563 | {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, 564 | ] 565 | jinja2 = [ 566 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 567 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 568 | ] 569 | markupsafe = [ 570 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, 571 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, 572 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, 573 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, 574 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, 575 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, 576 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, 577 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, 578 | {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, 579 | {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, 580 | {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, 581 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, 582 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, 583 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, 584 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, 585 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, 586 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, 587 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, 588 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, 589 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, 590 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, 591 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, 592 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, 593 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, 594 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, 595 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, 596 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, 597 | {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, 598 | {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, 599 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, 600 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, 601 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, 602 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, 603 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, 604 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, 605 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, 606 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, 607 | {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, 608 | {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, 609 | {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, 610 | ] 611 | mypy-extensions = [ 612 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 613 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 614 | ] 615 | packaging = [ 616 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 617 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 618 | ] 619 | pathspec = [ 620 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, 621 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, 622 | ] 623 | platformdirs = [ 624 | {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, 625 | {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, 626 | ] 627 | pluggy = [ 628 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 629 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 630 | ] 631 | py = [ 632 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 633 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 634 | ] 635 | pygments = [ 636 | {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, 637 | {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, 638 | ] 639 | pyparsing = [ 640 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 641 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 642 | ] 643 | pytest = [ 644 | {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, 645 | {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, 646 | ] 647 | pytz = [ 648 | {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, 649 | {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, 650 | ] 651 | requests = [ 652 | {file = "requests-2.28.0-py3-none-any.whl", hash = "sha256:bc7861137fbce630f17b03d3ad02ad0bf978c844f3536d0edda6499dafce2b6f"}, 653 | {file = "requests-2.28.0.tar.gz", hash = "sha256:d568723a7ebd25875d8d1eaf5dfa068cd2fc8194b2e483d7b1f7c81918dbec6b"}, 654 | ] 655 | snowballstemmer = [ 656 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 657 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 658 | ] 659 | sphinx = [ 660 | {file = "Sphinx-5.0.2-py3-none-any.whl", hash = "sha256:d3e57663eed1d7c5c50895d191fdeda0b54ded6f44d5621b50709466c338d1e8"}, 661 | {file = "Sphinx-5.0.2.tar.gz", hash = "sha256:b18e978ea7565720f26019c702cd85c84376e948370f1cd43d60265010e1c7b0"}, 662 | ] 663 | sphinxcontrib-applehelp = [ 664 | {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, 665 | {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, 666 | ] 667 | sphinxcontrib-devhelp = [ 668 | {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, 669 | {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, 670 | ] 671 | sphinxcontrib-htmlhelp = [ 672 | {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, 673 | {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, 674 | ] 675 | sphinxcontrib-jsmath = [ 676 | {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, 677 | {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, 678 | ] 679 | sphinxcontrib-qthelp = [ 680 | {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, 681 | {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, 682 | ] 683 | sphinxcontrib-serializinghtml = [ 684 | {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, 685 | {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, 686 | ] 687 | tomli = [ 688 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 689 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 690 | ] 691 | typed-ast = [ 692 | {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, 693 | {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, 694 | {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, 695 | {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, 696 | {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, 697 | {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, 698 | {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, 699 | {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, 700 | {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, 701 | {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, 702 | {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, 703 | {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, 704 | {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, 705 | {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, 706 | {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, 707 | {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, 708 | {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, 709 | {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, 710 | {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, 711 | {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, 712 | {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, 713 | {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, 714 | {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, 715 | {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, 716 | ] 717 | typing-extensions = [ 718 | {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, 719 | {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, 720 | ] 721 | urllib3 = [ 722 | {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, 723 | {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, 724 | ] 725 | zipp = [ 726 | {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, 727 | {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, 728 | ] 729 | -------------------------------------------------------------------------------- /spans/types.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import sys 3 | from collections import namedtuple 4 | from datetime import date, datetime, timedelta 5 | from functools import wraps 6 | 7 | from ._utils import PartialOrderingMixin, PicklableSlotMixin, date_from_iso_week 8 | 9 | __all__ = [ 10 | "intrange", 11 | "floatrange", 12 | "strrange", 13 | "daterange", 14 | "datetimerange", 15 | "timedeltarange", 16 | "PeriodRange", 17 | ] 18 | 19 | 20 | _internal_range = namedtuple( 21 | "_internal_range", 22 | ["lower", "upper", "lower_inc", "upper_inc", "empty"], 23 | ) 24 | _empty_internal_range = _internal_range(None, None, False, False, True) 25 | 26 | 27 | class _Bound(PartialOrderingMixin): 28 | __slots__ = ("value", "inc", "is_lower") 29 | 30 | def __init__(self, value, inc, is_lower): 31 | self.value = value 32 | self.inc = inc 33 | self.is_lower = is_lower 34 | 35 | def __repr__(self): 36 | rep = "_Bound({0.value!r}, inc={0.inc!r}, is_lower={0.is_lower!r})" 37 | return rep.format(self) 38 | 39 | def __lt__(self, other): 40 | # We need special cases when dealing with infinities 41 | if self.value is None: 42 | # If we are lower infinity we are always less than other, unless 43 | # other is also lower infinity. If we are upper infinity nothing 44 | # we can never be smaller than anything 45 | if self.is_lower: 46 | return other.value is not None or not other.is_lower 47 | else: 48 | return False 49 | 50 | if other.value is None: 51 | if other.is_lower: 52 | return False 53 | else: 54 | return self.value is not None or self.is_lower 55 | 56 | if self.value < other.value: 57 | return True 58 | elif self.value == other.value: 59 | if self.is_lower: 60 | if self.inc: 61 | return other.is_lower ^ other.inc 62 | return False 63 | else: 64 | if self.inc: 65 | return other.is_lower and not other.inc 66 | return other.is_lower or other.inc 67 | else: 68 | return False 69 | 70 | def __eq__(self, other): 71 | return all( 72 | getattr(self, attr) == getattr(other, attr) for attr in _Bound.__slots__ 73 | ) 74 | 75 | def adjacent(self, other): 76 | if self.value is None or other.value is None: 77 | return False 78 | 79 | if self.value != other.value: 80 | return False 81 | 82 | if self.is_lower == other.is_lower: 83 | return False 84 | 85 | return self.inc != other.inc 86 | 87 | 88 | class Range(PartialOrderingMixin, PicklableSlotMixin): 89 | """ 90 | Abstract base class of all ranges. 91 | 92 | Ranges are very strict about types. This means that both `lower` or `upper` 93 | must be of the given class or subclass or ``None``. 94 | 95 | All ranges are immutable. No default methods modify the range in place. 96 | Instead it returns a new instance. 97 | 98 | :param lower: Lower end of range. 99 | :param upper: Upper end of range. 100 | :param lower_inc: ``True`` if lower end should be included in range. Default 101 | is ``True`` 102 | :param upper_inc: ``True`` if upper end should be included in range. Default 103 | is ``False`` 104 | :raises TypeError: If lower or upper bound is not of the correct type. 105 | :raises ValueError: If upper bound is lower than lower bound. 106 | 107 | .. versionchanged:: 0.5.0 108 | Changed name from ``range_`` to ``Range`` 109 | 110 | .. note:: 111 | 112 | All examples in this class uses :class:`~spans.types.intrange` because 113 | this class is abstract. 114 | """ 115 | 116 | __slots__ = ("_range",) 117 | 118 | def __init__(self, lower=None, upper=None, lower_inc=None, upper_inc=None): 119 | if lower is not None and not isinstance(lower, self.type): 120 | raise TypeError( 121 | f"Invalid type for lower bound '{lower.__class__.__name__}'" 122 | f" expected '{self.type.__name__}'" 123 | ) 124 | 125 | if upper is not None and not isinstance(upper, self.type): 126 | raise TypeError( 127 | f"Invalid type for lower bound '{upper.__class__.__name__}'" 128 | f" expected '{self.type.__name__}'" 129 | ) 130 | 131 | # Verify that lower is less than or equal to upper if both are set to 132 | # prevent invalid ranges like [10,1) 133 | if lower is not None and upper is not None and upper < lower: 134 | raise ValueError( 135 | f"Upper bound ({upper}) is less than lower bound ({lower})" 136 | ) 137 | 138 | # Handle default values for lower_inc and upper_inc 139 | if lower_inc is None: 140 | lower_inc = lower is not None 141 | 142 | if upper_inc is None: 143 | upper_inc = False 144 | 145 | if lower is None and lower_inc: 146 | raise ValueError("Lower bound can not be inclusive when infinite") 147 | 148 | if upper is None and upper_inc: 149 | raise ValueError("Upper bound can not be inclusive when infinite") 150 | 151 | # Handle normalization to empty ranges 152 | if lower is not None and lower == upper and not (lower_inc and upper_inc): 153 | self._range = _empty_internal_range 154 | return 155 | 156 | self._range = _internal_range(lower, upper, lower_inc, upper_inc, False) 157 | 158 | @classmethod 159 | def empty(cls): 160 | """ 161 | Returns an empty set. An empty set is unbounded and only contain the 162 | empty set. 163 | 164 | >>> intrange.empty() in intrange.empty() 165 | True 166 | 167 | It is unbounded but the boundaries are not infinite. Its boundaries are 168 | returned as ``None``. Every set contains the empty set. 169 | """ 170 | 171 | self = cls.__new__(cls) 172 | self._range = _empty_internal_range 173 | return self 174 | 175 | @classmethod 176 | def is_valid_range(cls, obj): 177 | return isinstance(obj, cls) 178 | 179 | @classmethod 180 | def is_valid_scalar(cls, obj): 181 | return isinstance(obj, cls.type) 182 | 183 | def replace(self, *args, **kwargs): 184 | """ 185 | replace(lower=None, upper=None, lower_inc=None, upper_inc=None) 186 | 187 | Returns a new instance of self with the given arguments replaced. It 188 | takes the exact same arguments as the constructor. 189 | 190 | >>> intrange(1, 5).replace(upper=10) 191 | intrange(1, 10) 192 | >>> intrange(1, 10).replace(lower_inc=False) 193 | intrange(2, 10) 194 | >>> intrange(1, 10).replace(5) 195 | intrange(5, 10) 196 | 197 | Note that range objects are immutable and are never modified in place. 198 | """ 199 | 200 | params = { 201 | k: v for k, v in zip(("lower", "upper", "lower_inc", "upper_inc"), args) 202 | } 203 | params.update(kwargs) 204 | 205 | has_lower = "lower" in params 206 | has_lower_inc = "lower_inc" in params 207 | if not has_lower_inc and has_lower and params.get("lower") is None: 208 | params["lower_inc"] = False 209 | 210 | replacements = { 211 | "lower": self.lower, 212 | "upper": self.upper, 213 | "lower_inc": self.lower_inc, 214 | "upper_inc": self.upper_inc, 215 | } 216 | replacements.update(params) 217 | 218 | return self.__class__(**replacements) 219 | 220 | def __repr__(self): 221 | if not self: 222 | return f"{self.__class__.__name__}.empty()" 223 | out = [ 224 | str(self.__class__.__name__), 225 | "(", 226 | ] 227 | if self.lower is not None: 228 | out.append(repr(self.lower)) 229 | if self.upper is not None: 230 | out.append(", ") 231 | elif self.upper is not None: 232 | out.append("upper=") 233 | 234 | if self.upper is not None: 235 | out.append(repr(self.upper)) 236 | 237 | if not self.lower_inc and not self.lower_inf: 238 | out.append(", lower_inc=False") 239 | 240 | if self.upper_inc: 241 | out.append(", upper_inc=True") 242 | 243 | out.append(")") 244 | return "".join(out) 245 | 246 | @property 247 | def _lower_bound(self): 248 | return _Bound(self.lower, self.lower_inc, is_lower=True) 249 | 250 | @property 251 | def _upper_bound(self): 252 | return _Bound(self.upper, self.upper_inc, is_lower=False) 253 | 254 | @property 255 | def lower(self): 256 | """ 257 | Returns the lower boundary or None if it is unbounded. 258 | 259 | >>> intrange(1, 5).lower 260 | 1 261 | >>> intrange(upper=5).lower 262 | 263 | This is the same as the ``lower(self)`` in PostgreSQL. 264 | """ 265 | 266 | if self: 267 | return None if self.lower_inf else self._range.lower 268 | return None 269 | 270 | @property 271 | def upper(self): 272 | """ 273 | Returns the upper boundary or None if it is unbounded. 274 | 275 | >>> intrange(1, 5).upper 276 | 5 277 | >>> intrange(1).upper 278 | 279 | This is the same as the ``upper(self)`` in PostgreSQL. 280 | """ 281 | 282 | if self: 283 | return None if self.upper_inf else self._range.upper 284 | return None 285 | 286 | @property 287 | def lower_inc(self): 288 | """ 289 | Returns True if lower bound is included in range. If lower bound is 290 | unbounded this returns False. 291 | 292 | >>> intrange(1, 5).lower_inc 293 | True 294 | 295 | This is the same as the ``lower_inc(self)`` in PostgreSQL. 296 | """ 297 | 298 | return False if self.lower_inf else self._range.lower_inc 299 | 300 | @property 301 | def upper_inc(self): 302 | """ 303 | Returns True if upper bound is included in range. If upper bound is 304 | unbounded this returns False. 305 | 306 | >>> intrange(1, 5).upper_inc 307 | False 308 | 309 | This is the same as the ``upper_inc(self)`` in PostgreSQL. 310 | """ 311 | 312 | return False if self.upper_inf else self._range.upper_inc 313 | 314 | @property 315 | def lower_inf(self): 316 | """ 317 | Returns True if lower bound is unbounded. 318 | 319 | >>> intrange(1, 5).lower_inf 320 | False 321 | >>> intrange(upper=5).lower_inf 322 | True 323 | 324 | This is the same as the ``lower_inf(self)`` in PostgreSQL. 325 | """ 326 | 327 | return self._range.lower is None and not self._range.empty 328 | 329 | @property 330 | def upper_inf(self): 331 | """ 332 | Returns True if upper bound is unbounded. 333 | 334 | >>> intrange(1, 5).upper_inf 335 | False 336 | >>> intrange(1).upper_inf 337 | True 338 | 339 | This is the same as the ``upper_inf(self)`` in PostgreSQL. 340 | """ 341 | 342 | return self._range.upper is None and not self._range.empty 343 | 344 | def __eq__(self, other): 345 | if not self.is_valid_range(other): 346 | return NotImplemented 347 | return self._range == other._range 348 | 349 | def __lt__(self, other): 350 | if not self.is_valid_range(other): 351 | return NotImplemented 352 | 353 | # When dealing with empty ranges there is not such thing as order 354 | if not self or not other: 355 | return False 356 | 357 | if self._lower_bound == other._lower_bound: 358 | return self._upper_bound < other._upper_bound 359 | else: 360 | return self._lower_bound < other._lower_bound 361 | 362 | def __gt__(self, other): 363 | # We need to implement __gt__ even though we inherit from 364 | # PartialOrderingMixin since ranges are partially ordered (we can't 365 | # order empty ranges) 366 | if not self.is_valid_range(other): 367 | return NotImplemented 368 | 369 | # When dealing with empty ranges there is not such thing as order 370 | if not self or not other: 371 | return False 372 | 373 | # Use PartialOrderingMixin's implementation 374 | return super(Range, self).__gt__(other) 375 | 376 | def __bool__(self): 377 | return not self._range.empty 378 | 379 | def contains(self, other): 380 | """ 381 | Return True if this contains other. Other may be either range of same 382 | type or scalar of same type as the boundaries. 383 | 384 | >>> intrange(1, 10).contains(intrange(1, 5)) 385 | True 386 | >>> intrange(1, 10).contains(intrange(5, 10)) 387 | True 388 | >>> intrange(1, 10).contains(intrange(5, 10, upper_inc=True)) 389 | False 390 | >>> intrange(1, 10).contains(1) 391 | True 392 | >>> intrange(1, 10).contains(10) 393 | False 394 | 395 | Contains can also be called using the ``in`` operator. 396 | 397 | >>> 1 in intrange(1, 10) 398 | True 399 | 400 | This is the same as the ``self @> other`` in PostgreSQL. 401 | 402 | :param other: Object to be checked whether it exists within this range 403 | or not. 404 | :return: ``True`` if `other` is completely within this range, otherwise 405 | ``False``. 406 | :raises TypeError: If `other` is not of the correct type. 407 | """ 408 | 409 | if self.is_valid_range(other): 410 | if not self or not other: 411 | return not other 412 | return ( 413 | self._lower_bound <= other._lower_bound 414 | and other._upper_bound <= self._upper_bound 415 | ) 416 | elif self.is_valid_scalar(other): 417 | # If the lower bounary is not unbound we can safely perform the 418 | # comparison. Otherwise we'll try to compare a scalar to None, which 419 | # is bad 420 | is_within_lower = True 421 | if not self.lower_inf: 422 | lower_cmp = operator.le if self.lower_inc else operator.lt 423 | is_within_lower = lower_cmp(self.lower, other) 424 | 425 | # If the upper bounary is not unbound we can safely perform the 426 | # comparison. Otherwise we'll try to compare a scalar to None, which 427 | # is bad 428 | is_within_upper = True 429 | if not self.upper_inf: 430 | upper_cmp = operator.ge if self.upper_inc else operator.gt 431 | is_within_upper = upper_cmp(self.upper, other) 432 | 433 | return is_within_lower and is_within_upper 434 | else: 435 | raise TypeError( 436 | f"Unsupported type to test for inclusion {other.__class__.__name__!r}" 437 | ) 438 | 439 | def within(self, other): 440 | """ 441 | Tests if this range is within `other`. 442 | 443 | >>> a = intrange(1, 10) 444 | >>> b = intrange(3, 8) 445 | >>> a.contains(b) 446 | True 447 | >>> b.within(a) 448 | True 449 | 450 | This is the same as the ``self <@ other`` in PostgreSQL. One difference 451 | however is that unlike PostgreSQL ``self`` in this can't be a scalar 452 | value. 453 | 454 | :param other: Range to test against. 455 | :return: ``True`` if this range is completely within the given range, 456 | otherwise ``False``. 457 | :raises TypeError: If given range is of the wrong type. 458 | 459 | .. seealso:: 460 | This method is the inverse of :meth:`~spans.types.Range.contains` 461 | """ 462 | 463 | if not self.is_valid_range(other): 464 | raise TypeError( 465 | f"Unsupported type to test for inclusion {other.__class__.__name__!r}" 466 | ) 467 | return other.contains(self) 468 | 469 | def overlap(self, other): 470 | """ 471 | Returns True if both ranges share any points. 472 | 473 | >>> intrange(1, 10).overlap(intrange(5, 15)) 474 | True 475 | >>> intrange(1, 5).overlap(intrange(5, 10)) 476 | False 477 | 478 | This is the same as the ``&&`` operator for two ranges in PostgreSQL. 479 | 480 | :param other: Range to test against. 481 | :return: ``True`` if ranges overlap, otherwise ``False``. 482 | :raises TypeError: If `other` is of another type than this range. 483 | 484 | .. seealso:: 485 | If you need to know which part that overlapped, consider using 486 | :meth:`~spans.types.Range.intersection`. 487 | """ 488 | 489 | # Special case for empty ranges 490 | if not self or not other: 491 | return False 492 | 493 | sl = self._lower_bound 494 | su = self._upper_bound 495 | ol = other._lower_bound 496 | ou = other._upper_bound 497 | 498 | return sl < ou and ol < su 499 | 500 | def adjacent(self, other): 501 | """ 502 | Returns True if ranges are directly next to each other but does not 503 | overlap. 504 | 505 | >>> intrange(1, 5).adjacent(intrange(5, 10)) 506 | True 507 | >>> intrange(1, 5).adjacent(intrange(10, 15)) 508 | False 509 | 510 | The empty set is not adjacent to any set. 511 | 512 | This is the same as the ``-|-`` operator for two ranges in PostgreSQL. 513 | 514 | :param other: Range to test against. 515 | :return: ``True`` if this range is adjacent with `other`, otherwise 516 | ``False``. 517 | :raises TypeError: If given argument is of invalid type 518 | """ 519 | 520 | if not self.is_valid_range(other): 521 | raise TypeError( 522 | "Unsupported type to test for inclusion '{0.__class__.__name__}'".format( 523 | other 524 | ) 525 | ) 526 | # Must return False if either is an empty set 527 | elif not self or not other: 528 | return False 529 | return self._lower_bound.adjacent( 530 | other._upper_bound 531 | ) or self._upper_bound.adjacent(other._lower_bound) 532 | 533 | def union(self, other): 534 | """ 535 | Merges this range with a given range. 536 | 537 | >>> intrange(1, 5).union(intrange(5, 10)) 538 | intrange(1, 10) 539 | >>> intrange(1, 10).union(intrange(5, 15)) 540 | intrange(1, 15) 541 | 542 | Two ranges can not be merged if the resulting range would be split in 543 | two. This happens when the two sets are neither adjacent nor overlaps. 544 | 545 | >>> intrange(1, 5).union(intrange(10, 15)) 546 | Traceback (most recent call last): 547 | File "", line 1, in 548 | ValueError: Ranges must be either adjacent or overlapping 549 | 550 | This does not modify the range in place. 551 | 552 | This is the same as the ``+`` operator for two ranges in PostgreSQL. 553 | 554 | :param other: Range to merge with. 555 | :return: A new range that is the union of this and `other`. 556 | :raises ValueError: If `other` can not be merged with this range. 557 | """ 558 | 559 | if not self.is_valid_range(other): 560 | msg = "Unsupported type to test for union '{.__class__.__name__}'" 561 | raise TypeError(msg.format(other)) 562 | 563 | # Optimize empty ranges 564 | if not self: 565 | return other 566 | elif not other: 567 | return self 568 | 569 | if not self.overlap(other) and not self.adjacent(other): 570 | raise ValueError("Ranges must be either adjacent or overlapping") 571 | 572 | lower_bound = min(self._lower_bound, other._lower_bound) 573 | upper_bound = max(self._upper_bound, other._upper_bound) 574 | return self.__class__( 575 | lower_bound.value, 576 | upper_bound.value, 577 | lower_bound.inc, 578 | upper_bound.inc, 579 | ) 580 | 581 | def difference(self, other): 582 | """ 583 | Compute the difference between this and a given range. 584 | 585 | >>> intrange(1, 10).difference(intrange(10, 15)) 586 | intrange(1, 10) 587 | >>> intrange(1, 10).difference(intrange(5, 10)) 588 | intrange(1, 5) 589 | >>> intrange(1, 5).difference(intrange(5, 10)) 590 | intrange(1, 5) 591 | >>> intrange(1, 5).difference(intrange(1, 10)) 592 | intrange.empty() 593 | 594 | The difference can not be computed if the resulting range would be split 595 | in two separate ranges. This happens when the given range is completely 596 | within this range and does not start or end at the same value. 597 | 598 | >>> intrange(1, 15).difference(intrange(5, 10)) 599 | Traceback (most recent call last): 600 | File "", line 1, in 601 | ValueError: Other range must not be within this range 602 | 603 | This does not modify the range in place. 604 | 605 | This is the same as the ``-`` operator for two ranges in PostgreSQL. 606 | 607 | :param other: Range to difference against. 608 | :return: A new range that is the difference between this and `other`. 609 | :raises ValueError: If difference bethween this and `other` can not be 610 | computed. 611 | """ 612 | 613 | if not self.is_valid_range(other): 614 | msg = "Unsupported type to test for difference '{.__class__.__name__}'" 615 | raise TypeError(msg.format(other)) 616 | 617 | # Consider empty ranges or no overlap 618 | if not self or not other or not self.overlap(other): 619 | return self 620 | 621 | # If self is contained within other, the result is empty 622 | elif self in other: 623 | return self.empty() 624 | elif other in self and not (self.startswith(other) or self.endswith(other)): 625 | raise ValueError("Other range must not be within this range") 626 | elif self.endsbefore(other): 627 | return self.replace(upper=other.lower, upper_inc=not other.lower_inc) 628 | elif self.startsafter(other): 629 | return self.replace(lower=other.upper, lower_inc=not other.upper_inc) 630 | else: 631 | return self.empty() 632 | 633 | def intersection(self, other): 634 | """ 635 | Returns a new range containing all points shared by both ranges. If no 636 | points are shared an empty range is returned. 637 | 638 | >>> intrange(1, 5).intersection(intrange(1, 10)) 639 | intrange(1, 5) 640 | >>> intrange(1, 5).intersection(intrange(5, 10)) 641 | intrange.empty() 642 | >>> intrange(1, 10).intersection(intrange(5, 10)) 643 | intrange(5, 10) 644 | 645 | This is the same as the ``+`` operator for two ranges in PostgreSQL. 646 | 647 | :param other: Range to interect with. 648 | :return: A new range that is the intersection between this and `other`. 649 | """ 650 | 651 | if not self.is_valid_range(other): 652 | raise TypeError( 653 | f"Unsupported type to test for intersection {other.__class__.__name__!r}" 654 | ) 655 | 656 | # Handle ranges not intersecting 657 | if not self or not other or not self.overlap(other): 658 | return self.empty() 659 | 660 | lower_bound = max(self._lower_bound, other._lower_bound) 661 | upper_bound = min(self._upper_bound, other._upper_bound) 662 | return self.__class__( 663 | lower_bound.value, 664 | upper_bound.value, 665 | lower_bound.inc, 666 | upper_bound.inc, 667 | ) 668 | 669 | def startswith(self, other): 670 | """ 671 | Test if this range starts with `other`. `other` may be either range or 672 | scalar. 673 | 674 | >>> intrange(1, 5).startswith(1) 675 | True 676 | >>> intrange(1, 5).startswith(intrange(1, 10)) 677 | True 678 | 679 | :param other: Range or scalar to test. 680 | :return: ``True`` if this range starts with `other`, otherwise ``False`` 681 | :raises TypeError: If `other` is of the wrong type. 682 | """ 683 | 684 | if self.is_valid_range(other): 685 | if self.lower_inc == other.lower_inc: 686 | return self.lower == other.lower 687 | else: 688 | return False 689 | elif self.is_valid_scalar(other): 690 | if self.lower_inc: 691 | return self.lower == other 692 | else: 693 | return False 694 | else: 695 | raise TypeError( 696 | f"Unsupported type to test for starts with {other.__class__.__name__!r}" 697 | ) 698 | 699 | def endswith(self, other): 700 | """ 701 | Test if this range ends with `other`. `other` may be either range or 702 | scalar. 703 | 704 | >>> intrange(1, 5).endswith(4) 705 | True 706 | >>> intrange(1, 10).endswith(intrange(5, 10)) 707 | True 708 | 709 | :param other: Range or scalar to test. 710 | :return: ``True`` if this range ends with `other`, otherwise ``False`` 711 | :raises TypeError: If `other` is of the wrong type. 712 | """ 713 | 714 | if self.is_valid_range(other): 715 | if self.upper_inc == other.upper_inc: 716 | return self.upper == other.upper 717 | else: 718 | return False 719 | elif self.is_valid_scalar(other): 720 | if self.upper_inc: 721 | return self.upper == other 722 | else: 723 | return False 724 | else: 725 | raise TypeError( 726 | f"Unsupported type to test for ends with {other.__class__.__name__!r}" 727 | ) 728 | 729 | def startsafter(self, other): 730 | """ 731 | Test if this range starts after `other`. `other` may be either range or 732 | scalar. This only takes the lower end of the ranges into consideration. 733 | If the scalar or the lower end of the given range is greater than or 734 | equal to this range's lower end, ``True`` is returned. 735 | 736 | >>> intrange(1, 5).startsafter(0) 737 | True 738 | >>> intrange(1, 5).startsafter(intrange(0, 5)) 739 | True 740 | 741 | If ``other`` has the same start as the given 742 | 743 | :param other: Range or scalar to test. 744 | :return: ``True`` if this range starts after `other`, otherwise ``False`` 745 | :raises TypeError: If `other` is of the wrong type. 746 | """ 747 | 748 | if self.is_valid_range(other): 749 | if not self or not other: 750 | return False 751 | return self._lower_bound >= other._lower_bound 752 | elif self.is_valid_scalar(other): 753 | if not self: 754 | return False 755 | elif self.lower_inf: 756 | return True 757 | else: 758 | return self.lower >= other 759 | else: 760 | raise TypeError( 761 | f"Unsupported type to test for starts after {other.__class__.__name__!r}" 762 | ) 763 | 764 | def endsbefore(self, other): 765 | """ 766 | Test if this range ends before `other`. `other` may be either range or 767 | scalar. This only takes the upper end of the ranges into consideration. 768 | If the scalar or the upper end of the given range is less than or equal 769 | to this range's upper end, ``True`` is returned. 770 | 771 | >>> intrange(1, 5).endsbefore(5) 772 | True 773 | >>> intrange(1, 5).endsbefore(intrange(1, 5)) 774 | True 775 | 776 | :param other: Range or scalar to test. 777 | :return: ``True`` if this range ends before `other`, otherwise ``False`` 778 | :raises TypeError: If `other` is of the wrong type. 779 | """ 780 | 781 | if self.is_valid_range(other): 782 | if not self or not other: 783 | return False 784 | return self._upper_bound <= other._upper_bound 785 | elif self.is_valid_scalar(other): 786 | if not self: 787 | return False 788 | elif self.upper_inf: 789 | return True 790 | else: 791 | return self.upper <= other 792 | else: 793 | raise TypeError( 794 | f"Unsupported type to test for ends before {other.__class__.__name__!r}" 795 | ) 796 | 797 | def left_of(self, other): 798 | """ 799 | Test if this range `other` is strictly left of `other`. 800 | 801 | >>> intrange(1, 5).left_of(intrange(5, 10)) 802 | True 803 | >>> intrange(1, 10).left_of(intrange(5, 10)) 804 | False 805 | 806 | The bitwise right shift operator ``<<`` is overloaded for this operation 807 | too. 808 | 809 | >>> intrange(1, 5) << intrange(5, 10) 810 | True 811 | 812 | The choice of overloading ``<<`` might seem strange, but it is to mimick 813 | PostgreSQL's operators for ranges. As this is not obvious the use of 814 | ``<<`` is discouraged. 815 | 816 | :param other: Range to test against. 817 | :return: ``True`` if this range is completely to the left of ``other``. 818 | """ 819 | 820 | if not self.is_valid_range(other): 821 | raise TypeError( 822 | f"Left of is not supported for {other.__class__.__name__}, provide a proper range class" 823 | ) 824 | 825 | return self._upper_bound < other._lower_bound 826 | 827 | def right_of(self, other): 828 | """ 829 | Test if this range `other` is strictly right of `other`. 830 | 831 | >>> intrange(5, 10).right_of(intrange(1, 5)) 832 | True 833 | >>> intrange(1, 10).right_of(intrange(1, 5)) 834 | False 835 | 836 | The bitwise right shift operator ``>>`` is overloaded for this operation 837 | too. 838 | 839 | >>> intrange(5, 10) >> intrange(1, 5) 840 | True 841 | 842 | The choice of overloading ``>>`` might seem strange, but it is to mimick 843 | PostgreSQL's operators for ranges. As this is not obvious the use of 844 | ``>>`` is discouraged. 845 | 846 | :param other: Range to test against. 847 | :return: ``True`` if this range is completely to the right of ``other``. 848 | """ 849 | 850 | if not self.is_valid_range(other): 851 | raise TypeError( 852 | f"Right of is not supported for {other.__class__.__name__}, provide a proper range class" 853 | ) 854 | 855 | return other.left_of(self) 856 | 857 | def __lshift__(self, other): 858 | try: 859 | return self.left_of(other) 860 | except TypeError: 861 | return NotImplemented 862 | 863 | def __rshift__(self, other): 864 | try: 865 | return self.right_of(other) 866 | except TypeError: 867 | return NotImplemented 868 | 869 | def __or__(self, other): 870 | try: 871 | return self.union(other) 872 | except TypeError: 873 | return NotImplemented 874 | 875 | def __and__(self, other): 876 | try: 877 | return self.intersection(other) 878 | except TypeError: 879 | return NotImplemented 880 | 881 | def __sub__(self, other): 882 | try: 883 | return self.difference(other) 884 | except TypeError: 885 | return NotImplemented 886 | 887 | # ``in`` operator support 888 | __contains__ = contains 889 | 890 | 891 | class DiscreteRange(Range): 892 | """ 893 | DiscreteRange(lower=None, upper=None, lower_inc=None, upper_inc=None) 894 | 895 | Discrete ranges are a subset of ranges that works on discrete types. This 896 | includes ``int`` and ``datetime.date``. 897 | 898 | >>> intrange(0, 5, lower_inc=False) 899 | intrange(1, 5) 900 | >>> intrange(0, 5, lower_inc=False).lower_inc 901 | True 902 | 903 | All discrete ranges must provide a unit attribute containing the step 904 | length. For intrange this would be: 905 | 906 | .. code-block:: python 907 | 908 | class intrange(DiscreteRange): 909 | type = int 910 | unit = 1 911 | 912 | A range where no values can fit is considered empty: 913 | 914 | >>> intrange(0, 1, lower_inc=False) 915 | intrange.empty() 916 | 917 | Discrete ranges are iterable. 918 | 919 | >>> list(intrange(1, 5)) 920 | [1, 2, 3, 4] 921 | 922 | .. versionchanged:: 0.5.0 923 | Changed name from ``discreterange`` to ``DiscreteRange`` 924 | """ 925 | 926 | __slots__ = () 927 | 928 | def __init__(self, *args, **kwargs): 929 | super(DiscreteRange, self).__init__(*args, **kwargs) 930 | 931 | if self._range.empty: 932 | # If the range has already been normalized, 933 | # then we return here 934 | return 935 | 936 | # Normalize the internal range 937 | lb = self._range.lower 938 | if not self.lower_inf and not self._range.lower_inc: 939 | lb = self.next(lb) 940 | 941 | ub = self._range.upper 942 | if not self.upper_inf and self._range.upper_inc: 943 | ub = self.next(ub) 944 | 945 | if not self.lower_inf and not self.upper_inf and lb >= ub: 946 | self._range = _empty_internal_range 947 | else: 948 | self._range = _internal_range(lb, ub, True, False, False) 949 | 950 | @classmethod 951 | def next(cls, curr): 952 | """ 953 | Increment the given value with the step defined for this class. 954 | 955 | >>> intrange.next(1) 956 | 2 957 | 958 | :param curr: Value to increment. 959 | :return: Incremented value. 960 | """ 961 | 962 | return curr + cls.step 963 | 964 | @classmethod 965 | def prev(cls, curr): 966 | """ 967 | Decrement the given value with the step defined for this class. 968 | 969 | >>> intrange.prev(1) 970 | 0 971 | 972 | :param curr: Value to decrement. 973 | :return: Decremented value. 974 | """ 975 | 976 | return curr - cls.step 977 | 978 | @property 979 | def last(self): 980 | """ 981 | Returns the last element within this range. If the range has no upper 982 | limit ``None`` is returned. 983 | 984 | >>> intrange(1, 10).last 985 | 9 986 | >>> intrange(1, 10, upper_inc=True).last 987 | 10 988 | >>> intrange(1).last is None 989 | True 990 | 991 | :return: Last element within this range. 992 | 993 | .. versionadded:: 0.1.4 994 | """ 995 | 996 | if not self or self.upper_inf: 997 | return None 998 | else: 999 | # This is always valid since discrete sets are normalized to upper 1000 | # bound not included 1001 | return self.prev(self.upper) 1002 | 1003 | def endswith(self, other): 1004 | # Discrete ranges have a last element even in cases when upper bound is 1005 | # not included in set 1006 | if self.is_valid_scalar(other): 1007 | return self.last == other 1008 | else: 1009 | return super(DiscreteRange, self).endswith(other) 1010 | 1011 | def __iter__(self): 1012 | if self.lower_inf: 1013 | raise TypeError("Range with no lower bound can't be iterated over") 1014 | 1015 | value = self.lower 1016 | while self.upper_inf or value < self.upper: 1017 | yield value 1018 | value = self.next(value) 1019 | 1020 | def __reversed__(self): 1021 | if self.upper_inf: 1022 | raise TypeError("Range with no upper bound can't be iterated over") 1023 | 1024 | value = self.last 1025 | while self.lower_inf or value >= self.lower: 1026 | yield value 1027 | value = self.prev(value) 1028 | 1029 | 1030 | class OffsetableRangeMixin(object): 1031 | """ 1032 | Mixin for range types that supports being offset by a value. This value must 1033 | be of the same type as the range boundaries. For date types this will not 1034 | work and can be solved by explicitly defining an ``offset_type``: 1035 | 1036 | .. code-block:: python 1037 | 1038 | class datetimerange(Range, OffsetableRangeMixin): 1039 | __slots__ = () 1040 | 1041 | type = datetime 1042 | offset_type = timedelta 1043 | 1044 | .. versionchanged:: 0.5.0 1045 | Changed name from ``offsetablerange`` to ``OffsetableRangeMixin`` 1046 | """ 1047 | 1048 | __slots__ = () 1049 | 1050 | offset_type = None 1051 | 1052 | def offset(self, offset): 1053 | """ 1054 | Shift the range to the left or right with the given offset 1055 | 1056 | >>> intrange(0, 5).offset(5) 1057 | intrange(5, 10) 1058 | >>> intrange(5, 10).offset(-5) 1059 | intrange(0, 5) 1060 | >>> intrange.empty().offset(5) 1061 | intrange.empty() 1062 | 1063 | Note that range objects are immutable and are never modified in place. 1064 | 1065 | :param offset: Scalar to offset by. 1066 | 1067 | .. versionadded:: 0.1.3 1068 | """ 1069 | 1070 | # If range is empty it can't be offset 1071 | if not self: 1072 | return self 1073 | 1074 | offset_type = self.type if self.offset_type is None else self.offset_type 1075 | 1076 | if offset is not None and not isinstance(offset, offset_type): 1077 | raise TypeError( 1078 | f"Invalid type for offset '{offset.__class__.__name__!r}'" 1079 | f" expected '{offset_type.__name__}'" 1080 | ) 1081 | 1082 | lower = None if self.lower is None else self.lower + offset 1083 | upper = None if self.upper is None else self.upper + offset 1084 | 1085 | return self.replace(lower=lower, upper=upper) 1086 | 1087 | 1088 | class intrange(DiscreteRange, OffsetableRangeMixin): 1089 | """ 1090 | Range that operates on int. 1091 | 1092 | >>> intrange(1, 5) 1093 | intrange(1, 5) 1094 | 1095 | Inherits methods from :class:`~spans.types.Range`, 1096 | :class:`~spans.types.DiscreteRange` and :class:`~spans.types.OffsetableRangeMixin`. 1097 | """ 1098 | 1099 | __slots__ = () 1100 | 1101 | type = int 1102 | step = 1 1103 | 1104 | def __len__(self): 1105 | return self.upper - self.lower 1106 | 1107 | 1108 | class floatrange(Range, OffsetableRangeMixin): 1109 | """ 1110 | Range that operates on float. 1111 | 1112 | >>> floatrange(1.0, 5.0) 1113 | floatrange(1.0, 5.0) 1114 | >>> floatrange(None, 10.0, upper_inc=True) 1115 | floatrange(upper=10.0, upper_inc=True) 1116 | 1117 | Inherits methods from :class:`~spans.types.Range` and 1118 | :class:`~spans.types.OffsetableRangeMixin`. 1119 | """ 1120 | 1121 | __slots__ = () 1122 | 1123 | type = float 1124 | 1125 | 1126 | class strrange(DiscreteRange): 1127 | """ 1128 | Range that operates on unicode strings. Next character is determined 1129 | lexicographically. Representation might seem odd due to normalization. 1130 | 1131 | >>> strrange("a", "z") 1132 | strrange('a', 'z') 1133 | >>> strrange("a", "z", upper_inc=True) 1134 | strrange('a', '{') 1135 | 1136 | Iteration over a strrange is only sensible when having single character 1137 | boundaries. 1138 | 1139 | >>> list(strrange("a", "e", upper_inc=True)) 1140 | ['a', 'b', 'c', 'd', 'e'] 1141 | >>> len(list(strrange("aa", "zz", upper_inc=True))) # doctest: +SKIP 1142 | 27852826 1143 | 1144 | Inherits methods from :class:`~spans.types.Range` and 1145 | :class:`~spans.types.DiscreteRange`. 1146 | """ 1147 | 1148 | __slots__ = () 1149 | 1150 | type = str 1151 | 1152 | @classmethod 1153 | def next(cls, curr): 1154 | # Python's strings are ordered using lexical ordering 1155 | if not curr: 1156 | return "" 1157 | 1158 | last = curr[-1] 1159 | 1160 | # Make sure to loop around when we reach the maximum unicode point 1161 | if ord(last) == sys.maxunicode: 1162 | return cls.next(curr[:-1]) + chr(0) 1163 | else: 1164 | return curr[:-1] + chr(ord(curr[-1]) + 1) 1165 | 1166 | @classmethod 1167 | def prev(cls, curr): 1168 | # Python's strings are ordered using lexical ordering 1169 | if not curr: 1170 | return "" 1171 | 1172 | last = curr[-1] 1173 | 1174 | # Make sure to loop around when we reach the minimum unicode point 1175 | if ord(last) == 0: 1176 | return cls.prev(curr[:-1]) + chr(sys.maxunicode) 1177 | else: 1178 | return curr[:-1] + chr(ord(curr[-1]) - 1) 1179 | 1180 | 1181 | def _is_valid_date(obj, accept_none=True): 1182 | """ 1183 | Check if an object is an instance of, or a subclass deriving from, a 1184 | ``date``. However, it does not consider ``datetime`` or subclasses thereof 1185 | as valid dates. 1186 | 1187 | :param obj: Object to test as date. 1188 | :param accept_none: If True None is considered as a valid date object. 1189 | """ 1190 | 1191 | if accept_none and obj is None: 1192 | return True 1193 | return isinstance(obj, date) and not isinstance(obj, datetime) 1194 | 1195 | 1196 | class daterange(DiscreteRange, OffsetableRangeMixin): 1197 | """ 1198 | Range that operates on ``datetime.date``. 1199 | 1200 | >>> daterange(date(2015, 1, 1), date(2015, 2, 1)) 1201 | daterange(datetime.date(2015, 1, 1), datetime.date(2015, 2, 1)) 1202 | 1203 | Offsets are done using ``datetime.timedelta``. 1204 | 1205 | >>> daterange(date(2015, 1, 1), date(2015, 2, 1)).offset(timedelta(14)) 1206 | daterange(datetime.date(2015, 1, 15), datetime.date(2015, 2, 15)) 1207 | 1208 | Inherits methods from :class:`~spans.types.Range`, 1209 | :class:`~spans.types.DiscreteRange` and :class:`~spans.types.OffsetableRangeMixin`. 1210 | """ 1211 | 1212 | __slots__ = () 1213 | 1214 | type = date 1215 | offset_type = timedelta 1216 | step = timedelta(days=1) 1217 | 1218 | def __init__(self, lower=None, upper=None, lower_inc=None, upper_inc=None): 1219 | if not _is_valid_date(lower, accept_none=True): 1220 | raise TypeError( 1221 | f"Invalid type for lower bound '{lower.__class__.__name__}'" 1222 | f" expected '{self.type.__name__}'" 1223 | ) 1224 | 1225 | if not _is_valid_date(upper, accept_none=True): 1226 | raise TypeError( 1227 | f"Invalid type for upper bound '{upper.__class__.__name__}'" 1228 | f" expected '{self.type.__name__}'" 1229 | ) 1230 | 1231 | super(daterange, self).__init__(lower, upper, lower_inc, upper_inc) 1232 | 1233 | @classmethod 1234 | def from_date(cls, date, period=None): 1235 | """ 1236 | Create a day long daterange from for the given date. 1237 | 1238 | >>> daterange.from_date(date(2000, 1, 1)) 1239 | daterange(datetime.date(2000, 1, 1), datetime.date(2000, 1, 2)) 1240 | 1241 | :param date: A date to convert. 1242 | :param period: The period to normalize date to. A period may be one of: 1243 | ``day`` (default), ``week``, ``american_week``, 1244 | ``month``, ``quarter`` or ``year``. 1245 | :return: A new range that contains the given date. 1246 | 1247 | 1248 | .. seealso:: 1249 | 1250 | There are convenience methods for most period types: 1251 | :meth:`~spans.types.daterange.from_week`, 1252 | :meth:`~spans.types.daterange.from_month`, 1253 | :meth:`~spans.types.daterange.from_quarter` and 1254 | :meth:`~spans.types.daterange.from_year`. 1255 | 1256 | :class:`~spans.types.PeriodRange` has the same interface but is 1257 | period aware. This means it is possible to get things like next week 1258 | or month. 1259 | 1260 | .. versionchanged:: 0.4.0 1261 | Added the period parameter. 1262 | """ 1263 | 1264 | if period is None or period == "day": 1265 | return cls(date, date, upper_inc=True) 1266 | elif period == "week": 1267 | start = date - timedelta(date.weekday()) 1268 | return cls(start, start + timedelta(7)) 1269 | elif period == "american_week": 1270 | start = date - timedelta((date.weekday() + 1) % 7) 1271 | return cls(start, start + timedelta(7)) 1272 | elif period == "month": 1273 | start = date.replace(day=1) 1274 | return cls(start, (start + timedelta(31)).replace(day=1)) 1275 | elif period == "quarter": 1276 | start = date.replace(month=(date.month - 1) // 3 * 3 + 1, day=1) 1277 | return cls(start, (start + timedelta(93)).replace(day=1)) 1278 | elif period == "year": 1279 | start = date.replace(month=1, day=1) 1280 | return cls(start, (start + timedelta(366)).replace(day=1)) 1281 | else: 1282 | raise ValueError("Unexpected period, got {!r}".format(period)) 1283 | 1284 | @classmethod 1285 | def from_week(cls, year, iso_week): 1286 | """ 1287 | Create ``daterange`` based on a year and an ISO week 1288 | 1289 | :param year: Year as an integer 1290 | :param iso_week: ISO week number 1291 | :return: A new ``daterange`` for the given week 1292 | 1293 | .. versionadded:: 0.4.0 1294 | """ 1295 | 1296 | first_day = date_from_iso_week(year, iso_week) 1297 | return cls.from_date(first_day, period="week") 1298 | 1299 | # NOTE: from_american_week doesn't exist since I don't know enough about 1300 | # their calendar, if they even use enumerated weeks. 1301 | 1302 | @classmethod 1303 | def from_month(cls, year, month): 1304 | """ 1305 | Create ``daterange`` based on a year and amonth 1306 | 1307 | :param year: Year as an integer 1308 | :param iso_week: Month as an integer between 1 and 12 1309 | :return: A new ``daterange`` for the given month 1310 | 1311 | .. versionadded:: 0.4.0 1312 | """ 1313 | 1314 | first_day = date(year, month, 1) 1315 | return cls.from_date(first_day, period="month") 1316 | 1317 | @classmethod 1318 | def from_quarter(cls, year, quarter): 1319 | """ 1320 | Create ``daterange`` based on a year and quarter. 1321 | 1322 | A quarter is considered to be: 1323 | 1324 | - January through March (Q1), 1325 | - April through June (Q2), 1326 | - July through September (Q3) or, 1327 | - October through December (Q4) 1328 | 1329 | :param year: Year as an integer 1330 | :param quarter: Quarter as an integer between 1 and 4 1331 | :return: A new ``daterange`` for the given quarter 1332 | 1333 | .. versionadded:: 0.4.0 1334 | """ 1335 | 1336 | quarter_months = { 1337 | 1: 1, 1338 | 2: 4, 1339 | 3: 7, 1340 | 4: 10, 1341 | } 1342 | 1343 | if quarter not in quarter_months: 1344 | error_msg = ( 1345 | "quarter is not a valid quarter. Expected a value between 1 " 1346 | "and 4 got {!r}" 1347 | ) 1348 | raise ValueError(error_msg.format(quarter)) 1349 | 1350 | first_day = date(year, quarter_months[quarter], 1) 1351 | return cls.from_date(first_day, period="quarter") 1352 | 1353 | @classmethod 1354 | def from_year(cls, year): 1355 | """ 1356 | Create ``daterange`` based on a year 1357 | 1358 | :param year: Year as an integer 1359 | :return: A new ``daterange`` for the given year 1360 | 1361 | .. versionadded:: 0.4.0 1362 | """ 1363 | 1364 | first_day = date(year, 1, 1) 1365 | return cls.from_date(first_day, period="year") 1366 | 1367 | def __len__(self): 1368 | """ 1369 | Returns number of dates in range. 1370 | 1371 | >>> len(daterange(date(2013, 1, 1), date(2013, 1, 8))) 1372 | 7 1373 | 1374 | """ 1375 | 1376 | if self.lower_inf or self.upper_inf: 1377 | raise ValueError("Unbounded ranges don't have a length") 1378 | 1379 | return (self.upper - self.lower).days 1380 | 1381 | 1382 | class datetimerange(Range, OffsetableRangeMixin): 1383 | """ 1384 | Range that operates on ``datetime.datetime``. 1385 | 1386 | >>> datetimerange(datetime(2015, 1, 1), datetime(2015, 2, 1)) 1387 | datetimerange(datetime.datetime(2015, 1, 1, 0, 0), datetime.datetime(2015, 2, 1, 0, 0)) 1388 | 1389 | Offsets are done using ``datetime.timedelta``. 1390 | 1391 | >>> datetimerange( 1392 | ... datetime(2015, 1, 1), datetime(2015, 2, 1)).offset(timedelta(14)) 1393 | datetimerange(datetime.datetime(2015, 1, 15, 0, 0), datetime.datetime(2015, 2, 15, 0, 0)) 1394 | 1395 | Inherits methods from :class:`~spans.types.Range` and :class:`~spans.types.OffsetableRangeMixin`. 1396 | """ 1397 | 1398 | __slots__ = () 1399 | 1400 | type = datetime 1401 | offset_type = timedelta 1402 | 1403 | 1404 | class timedeltarange(Range, OffsetableRangeMixin): 1405 | """ 1406 | Range that operates on datetime's timedelta class. 1407 | 1408 | >>> timedeltarange(timedelta(1), timedelta(5)) 1409 | timedeltarange(datetime.timedelta(days=1), datetime.timedelta(days=5)) 1410 | 1411 | Offsets are done using ``datetime.timedelta``. 1412 | 1413 | >>> timedeltarange(timedelta(1), timedelta(5)).offset(timedelta(14)) 1414 | timedeltarange(datetime.timedelta(days=15), datetime.timedelta(days=19)) 1415 | 1416 | Inherits methods from :class:`~spans.types.Range` and 1417 | :class:`~spans.types.OffsetableRangeMixin`. 1418 | """ 1419 | 1420 | __slots__ = () 1421 | 1422 | type = timedelta 1423 | 1424 | 1425 | class PeriodRange(daterange): 1426 | """ 1427 | A type aware version of :class:`~spans.types.daterange`. 1428 | 1429 | Type aware refers to being aware of what kind of range it represents. 1430 | Available types are the same as the ``period`` argument for to 1431 | :meth:`~spans.types.daterange.from_date`. 1432 | 1433 | Some methods are unavailable due since they don't make sense for 1434 | :class:`~spans.types.PeriodRange`, and some may return a normal 1435 | :class:`~spans.types.daterange` since they may modifify the range in ways 1436 | not compatible with its type. 1437 | 1438 | .. versionadded:: 0.4.0 1439 | 1440 | .. note:: 1441 | 1442 | This class does not have its own range set implementation, but can be 1443 | used with :class:`~spans.settypes.daterangeset`. 1444 | """ 1445 | 1446 | __slots__ = "period" 1447 | 1448 | @classmethod 1449 | def empty(cls): 1450 | """ 1451 | :raise TypeError: since typed date ranges must never be empty 1452 | """ 1453 | 1454 | raise TypeError(f"{cls.__name__} does not support empty ranges") 1455 | 1456 | # We override the is valid check here because dateranges will not be 1457 | # accepted as valid arguments otherwise. 1458 | @classmethod 1459 | def is_valid_range(cls, other): 1460 | return isinstance(other, daterange) 1461 | 1462 | @classmethod 1463 | def from_date(cls, day, period=None): 1464 | span = daterange.from_date(day, period=period) 1465 | 1466 | new_span = cls() 1467 | new_span._range = span._range 1468 | new_span.period = period 1469 | 1470 | return new_span 1471 | 1472 | def __repr__(self): 1473 | parent_repr = super(PeriodRange, self).__repr__() 1474 | return f"{parent_repr[:-1]}, period={self.period!r})" 1475 | 1476 | @property 1477 | def daterange(self): 1478 | """ 1479 | This ``PeriodRange`` represented as a naive 1480 | :class:`~spans.types.daterange`. 1481 | """ 1482 | 1483 | return daterange( 1484 | lower=self.lower, 1485 | upper=self.upper, 1486 | lower_inc=self.lower_inc, 1487 | upper_inc=self.upper_inc, 1488 | ) 1489 | 1490 | def offset(self, offset): 1491 | """ 1492 | Offset the date range by the given amount of periods. 1493 | 1494 | This differs from :meth:`~spans.types.OffsetableRangeMixin.offset` on 1495 | :class:`spans.types.daterange` by not accepting a ``timedelta`` object. 1496 | Instead it expects an integer to adjust the typed date range by. The 1497 | given value may be negative as well. 1498 | 1499 | :param offset: Number of periods to offset this range by. A period is 1500 | either a day, week, american week, month, quarter or 1501 | year, depending on this range's period type. 1502 | :return: New offset :class:`~spans.types.PeriodRange` 1503 | """ 1504 | 1505 | span = self 1506 | if offset > 0: 1507 | for i in range(offset): 1508 | span = span.next_period() 1509 | elif offset < 0: 1510 | for i in range(-offset): 1511 | span = span.prev_period() 1512 | return span 1513 | 1514 | def prev_period(self): 1515 | """ 1516 | The period before this range. 1517 | 1518 | >>> span = PeriodRange.from_date(date(2000, 1, 1), period="month") 1519 | >>> span.prev_period() 1520 | PeriodRange(datetime.date(1999, 12, 1), datetime.date(2000, 1, 1), period='month') 1521 | 1522 | :return: A new :class:`~spans.types.PeriodRange` for the period 1523 | before this period 1524 | """ 1525 | 1526 | return self.from_date(self.prev(self.lower), period=self.period) 1527 | 1528 | def next_period(self): 1529 | """ 1530 | The period after this range. 1531 | 1532 | >>> span = PeriodRange.from_date(date(2000, 1, 1), period="month") 1533 | >>> span.next_period() 1534 | PeriodRange(datetime.date(2000, 2, 1), datetime.date(2000, 3, 1), period='month') 1535 | 1536 | :return: A new :class:`~spans.types.PeriodRange` for the period after 1537 | this period 1538 | """ 1539 | 1540 | # We can use this shortcut since dateranges are always normalized 1541 | return self.from_date(self.upper, period=self.period) 1542 | 1543 | # Override methods that modifies the range to return a daterange instead 1544 | def replace(self, *args, **kwargs): 1545 | return self.daterange.replace(*args, **kwargs) 1546 | 1547 | def union(self, other): 1548 | return self.daterange.union(other) 1549 | 1550 | def intersection(self, other): 1551 | return self.daterange.intersection(other) 1552 | 1553 | def difference(self, other): 1554 | return self.daterange.difference(other) 1555 | 1556 | 1557 | # Legacy names 1558 | 1559 | #: This alias exist for legacy reasons. It is considered deprecated but will not 1560 | #: likely be removed. 1561 | #: 1562 | #: .. versionadded:: 0.5.0 1563 | range_ = Range 1564 | 1565 | 1566 | #: This alias exist for legacy reasons. It is considered deprecated but will not 1567 | #: likely be removed. 1568 | #: 1569 | #: .. versionadded:: 0.5.0 1570 | discreterange = DiscreteRange 1571 | 1572 | 1573 | #: This alias exist for legacy reasons. It is considered deprecated but will not 1574 | #: likely be removed. 1575 | #: 1576 | #: .. versionadded:: 0.5.0 1577 | offsetablerange = OffsetableRangeMixin 1578 | --------------------------------------------------------------------------------