├── ranges ├── py.typed ├── __init__.py ├── _helper.py ├── RangeSet.py ├── Range.py └── RangeDict.py ├── test ├── __init__.py ├── test_base.py ├── test_issues.py ├── test_Range.py └── test_RangeSet.py ├── docs ├── set.rst ├── dict.rst ├── about.rst ├── requirements-rtd.txt ├── index.rst └── conf.py ├── Makefile ├── .readthedocs.yml ├── make.bat ├── setup.py ├── LICENSE.txt ├── .gitignore └── readme.md /ranges/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/set.rst: -------------------------------------------------------------------------------- 1 | The set 2 | ======= 3 | 4 | 5 | .. autoclass:: ranges.RangeSet::RangeSet 6 | :member-order: groupwise 7 | :no-undoc-members: 8 | -------------------------------------------------------------------------------- /docs/dict.rst: -------------------------------------------------------------------------------- 1 | The Dictionary 2 | ============== 3 | 4 | .. autoclass:: ranges.RangeDict::RangeDict 5 | :member-order: groupwise 6 | :no-undoc-members: 7 | -------------------------------------------------------------------------------- /docs/about.rst: -------------------------------------------------------------------------------- 1 | Using continuous ranges 2 | ======================= 3 | 4 | .. autoclass:: ranges.Range::Range 5 | :no-undoc-members: 6 | :member-order: groupwise 7 | -------------------------------------------------------------------------------- /ranges/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The `ranges` module contains the data structures Range, RangeSet, 3 | and RangeDict. 4 | 5 | See http://pypi.org/project/python-ranges for information. 6 | 7 | See http://github.com/superbird11/ranges for source code. 8 | """ 9 | 10 | from .Range import Range 11 | from .RangeSet import RangeSet 12 | from .RangeDict import RangeDict 13 | from ._helper import Inf, Rangelike, RangelikeString 14 | 15 | __all__ = ["Range", "RangeSet", "RangeDict", "Inf", "Rangelike", "RangelikeString"] 16 | name = "ranges" 17 | -------------------------------------------------------------------------------- /docs/requirements-rtd.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.13 2 | Babel==2.11.0 3 | certifi==2022.12.7 4 | charset-normalizer==3.0.1 5 | docutils==0.19 6 | idna==3.4 7 | imagesize==1.4.1 8 | Jinja2==3.1.2 9 | MarkupSafe==2.1.2 10 | packaging==23.0 11 | Pygments==2.14.0 12 | python-ranges==1.2.2 13 | pytz==2022.7.1 14 | requests==2.28.2 15 | snowballstemmer==2.2.0 16 | Sphinx==6.1.3 17 | sphinxcontrib-applehelp==1.0.4 18 | sphinxcontrib-devhelp==1.0.2 19 | sphinxcontrib-htmlhelp==2.0.1 20 | sphinxcontrib-jsmath==1.0.1 21 | sphinxcontrib-qthelp==1.0.3 22 | sphinxcontrib-serializinghtml==1.1.5 23 | urllib3==1.26.14 24 | -------------------------------------------------------------------------------- /test/test_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing helper functions 3 | """ 4 | from pytest import fail 5 | 6 | 7 | def asserterror(errortype, func, args=None, kwargs=None): 8 | """ 9 | A helper method - basically, "execute the given function with the given args and 10 | assert that it produces the correct error type. 11 | """ 12 | if args is None: 13 | args = [] 14 | if kwargs is None: 15 | kwargs = {} 16 | try: 17 | func(*args, **kwargs) 18 | fail(f"Function {func.__name__} with args {args} and kwargs {kwargs} should have raised {errortype.__name__}") 19 | except errortype: 20 | pass -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. python-ranges documentation master file, created by 2 | sphinx-quickstart on Thu Apr 14 22:47:09 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ``python-ranges`` - Continuous Range, RangeSet, and RangeDict data structures for Python 7 | ======================================================================================== 8 | 9 | .. toctree:: 10 | 11 | Continuous ranges 12 | Ranges as a dictionary 13 | Ranges as a set 14 | 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = ./docs 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | # Set the version of Python and other tools you might need 10 | build: 11 | os: ubuntu-22.04 12 | tools: 13 | python: "3.11" 14 | # You can also specify other tool versions: 15 | # nodejs: "19" 16 | # rust: "1.64" 17 | # golang: "1.19" 18 | 19 | # Build documentation in the docs/ directory with Sphinx 20 | sphinx: 21 | configuration: ./docs/conf.py 22 | # Optionally declare the Python requirements required to build your docs 23 | python: 24 | install: 25 | - requirements: ./docs/requirements-rtd.txt 26 | # Optionally build your docs in additional formats such as PDF and ePub 27 | formats: [] 28 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("readme.md", "r") as fh: 4 | setuptools.setup( 5 | name="python-ranges", 6 | version="1.2.2", 7 | author="Louis Jacobowitz", 8 | author_email="ldjacobowitzer@gmail.com", 9 | description="""Continuous Range, RangeSet, and RangeDict data structures""", 10 | long_description_content_type="text/markdown", 11 | long_description=fh.read(), 12 | url="https://github.com/superbird11/ranges", 13 | packages=setuptools.find_packages(), 14 | package_data={ 15 | "ranges": ["py.typed"] 16 | }, 17 | classifiers=[ 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ], 22 | project_urls={ 23 | 'Documentation': 'https://python-ranges.readthedocs.io/en/latest/', 24 | 'GitHub': 'https://github.com/Superbird11/ranges', 25 | }, 26 | python_requires='>=3.9', 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2019-2022 Louis Jacobowitz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/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 | sys.path.insert(0, os.path.abspath('../ranges')) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'python-ranges' 22 | copyright = '2022, Louis Jacobowitz' 23 | author = 'Louis Jacobowitz' 24 | 25 | master_doc = 'index' 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 43 | 44 | # represent classes as 'Class' rather than 'module.Class' 45 | add_module_names = False 46 | autodoc_typehints = 'none' 47 | autodoc_typehints_format = 'short' 48 | autodoc_preserve_defaults = True 49 | 50 | autodoc_default_options = { 51 | 'members': 'True', 52 | 'special-members': '__init__', 53 | } 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | 57 | # The theme to use for HTML and HTML Help pages. See the documentation for 58 | # a list of builtin themes. 59 | # 60 | html_theme = 'alabaster' 61 | 62 | # Add any paths that contain custom static files (such as style sheets) here, 63 | # relative to this directory. They are copied after the builtin static files, 64 | # so a file named "default.css" will overwrite the builtin "default.css". 65 | html_static_path = ['_static'] 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | deploy.sh 3 | .idea/ 4 | bin/ 5 | include/ 6 | .DS_Store 7 | pip-selfcheck.json 8 | pyvenv.cfg 9 | 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | **.pyc 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .env 117 | .venv 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | -------------------------------------------------------------------------------- /test/test_issues.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for github issues and bugfixes, specifically 3 | """ 4 | from pytest import fail 5 | from ranges import Range, RangeSet, RangeDict 6 | 7 | 8 | def test_issue4(): 9 | # issue: a Range that exactly overlaps one exclusive border of a key in a RangeDict 10 | # does not register as contains 11 | # cause: Range._above_start() and ._below_end() were disregarding the other Range's inclusivity 12 | # by not treating the other Range as a Range 13 | rd = RangeDict({Range(0, 5, include_end=True): 'zero to five inclusive'}) 14 | assert(Range(0, 5, include_end=True) in rd) 15 | assert(Range(0, 4) in rd) 16 | assert(Range(1, 4) in rd) 17 | assert(Range(1, 5, include_end=True) in rd) 18 | rd2 = RangeDict({Range(0, 5, include_start=False): 'zero to five exclusive'}) 19 | assert(Range(0, 5, include_start=False) in rd2) 20 | assert(Range(0, 4, include_start=False) in rd2) 21 | assert(Range(1, 4) in rd2) 22 | assert(Range(1, 5) in rd2) 23 | 24 | 25 | def test_issue6(): 26 | # issue: cannot use unhashable types as the value in a RangeDict 27 | try: 28 | x = RangeDict({Range(0, 1): ["A", "B"]}) 29 | assert(str(x) == "{{[0, 1)}: ['A', 'B']}") 30 | except TypeError: 31 | fail("RangeDict should not raise an error when value is unhashable") 32 | 33 | 34 | def test_issue8(): 35 | # issue: adding a Range to a RangeSet containing two non-overlapping ranges, such that the new range overlaps 36 | # with one but not the other, leads to a TypeError being raised. 37 | # cause: code was passing a Linked List Node instead of the node's value (a range) 38 | try: 39 | a = RangeSet() 40 | a.add(Range(100, 300)) 41 | a.add(Range(400, 500)) 42 | a.add(Range(500, 600)) 43 | assert(str(a) == "{[100, 300), [400, 600)}") 44 | b = RangeSet() 45 | b.add(Range(400, 600)) 46 | b.add(Range(200, 300)) 47 | b.add(Range(100, 200)) 48 | assert(str(b) == "{[100, 300), [400, 600)}") 49 | except TypeError: 50 | fail("RangeSet should not have an issue concatenating to the second range of two in a RangeSet") 51 | 52 | 53 | def test_issue12(): 54 | # issue: mutating a mutable RangeDict value also affected all keys set to equivalent values. 55 | # In other words, the RangeDict was compressing equal but not identical values into the same 56 | # rangekey values. To fix, added a toggle to use identity instead of equality. 57 | # The code in this test is now also contained in the docstring. 58 | f = RangeDict({Range(1, 2): {3}, Range(4, 5): {3}}) 59 | assert(str(f) == '{{[1, 2), [4, 5)}: {3}}') 60 | f[Range(1, 2)] |= {4} 61 | assert(str(f) == '{{[1, 2), [4, 5)}: {3, 4}}') 62 | 63 | g = RangeDict({Range(1, 2): {3}, Range(4, 5): {3}}, identity=True) 64 | assert(str(g) == '{{[1, 2)}: {3}, {[4, 5)}: {3}}') 65 | 66 | h = RangeDict({Range(1, 2): {3}, Range(4, 5): {3}}) 67 | assert(str(h) == '{{[1, 2), [4, 5)}: {3}}') 68 | h[Range(1, 2)] = h[Range(1, 2)] | {4} 69 | assert(str(h) == '{{[4, 5)}: {3}, {[1, 2)}: {3, 4}}') 70 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # `python-ranges` 2 | 3 | This module provides data structures for representing 4 | 5 | - Continuous Ranges 6 | - Non-continuous Ranges (i.e. sets of continous Ranges) 7 | - `dict`-like structures that use ranges as keys 8 | 9 | ## Introduction 10 | 11 | One curious missing feature in Python (and several other programming languages) is 12 | the absence of a proper Range data structure - a _continuous_ set of values from some 13 | starting point to some ending point. Python's built-in `range()` produces an object 14 | that can be used to iterate over numbers, but it's not continuous (e.g. 15 | `1.5 in range(1, 2)` returns `False`) and doesn't work 16 | for non-numeric types like dates. Instead, we have to make do with verbose 17 | `if`/`else` comparisons: 18 | 19 | ```python 20 | if value >= start and value < end: 21 | # do something 22 | ``` 23 | 24 | And to have a graded sequence of ranges with different behavior for each, we have 25 | to chain these `if`/`elif`/`else` blocks together: 26 | 27 | ```python 28 | # 2019 U.S. income tax brackets, filing Single 29 | income = int(input("What is your income? $")) 30 | if income < 9701: 31 | tax = 0.1 * income 32 | elif 9701 <= income < 39476: 33 | tax = 970 + 0.12 * (income - 9700) 34 | elif 39476 <= income < 84201: 35 | tax = 4543 + 0.22 * (income - 39475) 36 | elif 84201 <= income < 160726: 37 | tax = 14382.5 + 0.24 * (income - 84200) 38 | elif 160726 <= income < 204101: 39 | tax = 32748.5 + 0.32 * (income - 160725) 40 | elif 204101 <= income < 510301: 41 | tax = 46628.5 + 0.35 * (income - 204100) 42 | else: 43 | tax = 153798.5 + 0.37 * (income - 510300) 44 | print(f"Your tax on that income is ${tax:.2f}") 45 | ``` 46 | 47 | And if we want to restrict a user input to within certain bounds, we need to do 48 | some complicated, verbose construct like this: 49 | 50 | ```python 51 | user_input = int(input()) 52 | value_we_want = min(max(user_input, start), end) 53 | ``` 54 | 55 | This module, `ranges`, fixes this problem by introducing a data structure `Range` to 56 | represent a continuous range, and a `dict`-like data structure `RangeDict` to map 57 | ranges to values. This makes simple range checks more intuitive: 58 | 59 | ```python 60 | if value in Range(start, end): 61 | # do something 62 | 63 | user_input = int(input()) 64 | value_we_want = Range(start, end).clamp(user_input) 65 | ``` 66 | 67 | and does away with the tedious `if`/`elif`/`else` blocks: 68 | 69 | ```python 70 | # 2019 U.S. income tax brackets, filing Single 71 | tax_info = RangeDict({ 72 | Range(0, 9701): (0, 0.10, 0), 73 | Range(9701, 39476): (970, 0.12, 9700), 74 | Range(39476, 84201): (4543, 0.22, 39475), 75 | Range(84201, 160726): (14382.2, 0.24, 84200), 76 | Range(160726, 204101): (32748.5, 0.32, 160725), 77 | Range(204101, 510301): (46628.5, 0.35, 204100), 78 | Range(start=510301): (153798.5, 0.37, 510300), 79 | }) 80 | income = int(input("What is your income? $")) 81 | base, marginal_rate, bracket_floor = tax_info[income] 82 | tax = base + marginal_rate * (income - bracket_floor) 83 | print(f"Your tax on that income is ${tax:.2f}") 84 | ``` 85 | 86 | The `Range` data structure also accepts strings, dates, and any other data type, so 87 | long as the start value is less than the end value (and so long as checking that 88 | doesn't raise an error). 89 | 90 | 91 | See [the in-depth documentation](https://python-ranges.readthedocs.io/en/latest/) for more details. 92 | 93 | ## Installation 94 | 95 | Install `python-ranges` via [pip](https://pip.pypa.io/en/stable/): 96 | 97 | ```bash 98 | $ pip install python-ranges 99 | ``` 100 | 101 | Due to use of format strings in the code, this module will only work with 102 | **python 3.6 or higher**. Some post-3.9 features are also used in the module's 103 | type hinting, which may pose a problem for earlier versions of python. 104 | 105 | ## Usage 106 | 107 | Simply import `ranges` like any other python package, or import the `Range`, 108 | `RangeSet`, and `RangeDict` classes from it: 109 | 110 | ```python 111 | import ranges 112 | 113 | my_range = ranges.Range("anaconda", "viper") 114 | ``` 115 | 116 | ```python 117 | from ranges import Range 118 | 119 | my_range = Range("anaconda", "viper") 120 | ``` 121 | 122 | Then, you can use these data types however you like. 123 | 124 | ### `Range` 125 | 126 | To make a Range, simply call `Range()` with start and end values. Both of these 127 | work: 128 | 129 | ```python 130 | rng1 = Range(1.5, 7) 131 | rng2 = Range(start=4, end=8.5) 132 | ``` 133 | 134 | You can also use the `include_start` and `include_end` keyword arguments to specify 135 | whether or not each end of the range should be inclusive. By default, the start 136 | is included and the end is excluded, just like python's built-in `range()` function. 137 | 138 | If you use keyword arguments and don't specify either the `start` or the `end` of 139 | the range, then the `Range`'s bounds will be negative or positive infinity, 140 | respectively. `Range` uses a special notion of infinity that's compatible with 141 | non-numeric data types - so `Range(start="journey")` will include *any string* 142 | that's lexicographically greater than "journey", and 143 | `Range(end=datetime.date(1989, 10, 4))` will include any date before October 4, 144 | 1989, despite neither `str` nor `datetime` having any built-in notion of infinity. 145 | 146 | You can import `Inf` in order to invoke this infinity explicitly: 147 | 148 | ```python 149 | from ranges import Range, Inf 150 | rngA = Range(-Inf, Inf) 151 | rngB = Range() 152 | # rng1 and rng2 are identical 153 | ``` 154 | 155 | and you can check whether a range is infinite on either end by calling `.isinfinite()`: 156 | 157 | ```python 158 | rngC = Range(end=0) 159 | rngD = Range(start=0) 160 | rngE = Range(-1, 1) 161 | print(rng1.isinfinite(), rng2.isinfinite(), rng3.isinfinite()) 162 | # True True False 163 | ``` 164 | 165 | If you're making a range of numbers, then you can also use a single string as an 166 | argument, with circle-brackets `()` meaning "exclusive" and square-brackets `[]` 167 | meaning "inclusive": 168 | 169 | ```python 170 | rng3 = Range("[1.5, 7)") 171 | rng4 = Range("[1.5 .. 7)") 172 | ``` 173 | 174 | `Range`'s interface is similar to the built-in `set`, and the following methods 175 | all act exactly how you'd expect: 176 | 177 | ```python 178 | print(rng1.union(rng2)) # [1.5, 8.5) 179 | print(rng1.intersection(rng2)) # [4, 7) 180 | print(rng1.difference(rng2)) # [1.5, 4) 181 | print(rng1.symmetric_difference(rng2)) # {[1.5, 4), [7, 8.5)} 182 | ``` 183 | 184 | Of course, the operators `|`, `&`, `-`, and `^` can be used in place of those 185 | methods, just like for python's built-in `set`s. 186 | 187 | See [the documentation](https://python-ranges.readthedocs.io/en/latest/#ranges.Range) for more details. 188 | 189 | ### `RangeSet` 190 | 191 | A `RangeSet` is just an ordered set of `Range`s, all of the same kind. Like `Range`, 192 | its interface is similar to the built-in `set`. Unlike `Range`, which isn't 193 | mutable, `RangeSet` can be modified just like `set` can, with the methods 194 | `.add()`, `.extend()`, `.discard()`, etc. 195 | 196 | To construct a `RangeSet`, just call `RangeSet()` with a bunch of ranges (or 197 | iterables containing ranges) as positional arguments: 198 | 199 | ```python 200 | rngset1 = RangeSet("[1, 4.5]", "(6.5, 10)") 201 | rngset2 = RangeSet([Range(2, 3), Range(7, 8)]) 202 | ``` 203 | 204 | `Range` and `RangeSet` objects are mutually compatible for things like `union()`, 205 | `intersection()`, `difference()`, and `symmetric_difference()`. If you give these 206 | methods a range-like object, it'll get automatically converted: 207 | 208 | ```python 209 | print(rngset1.union(Range(3, 8))) # {[1, 10)} 210 | print(rngset1.intersection("[3, 8)")) # {[3, 4.5], (6.5, 8)} 211 | print(rngset1.symmetric_difference("[3, 8)")) # {[1, 3), (4.5, 6], [8, 10)} 212 | ``` 213 | 214 | Of course, `RangeSet`s can operate with each other, too: 215 | 216 | ```python 217 | print(rngset1.difference(rngset2)) # {[1, 2), [3, 4.5], (6.5, 7), [8, 10)} 218 | ``` 219 | 220 | The operators `|`, `&`, `^`, and `-` all work with `RangeSet` as they do with `set`, 221 | as do their associated assignment operators `|=`, `&=`, `^=`, and `-=`. 222 | 223 | Finally, you can iterate through a `RangeSet` to get all of its component ranges: 224 | 225 | ```python 226 | for rng in rngset1: 227 | print(rng) 228 | # [1, 4.5] 229 | # (6.5, 10) 230 | ``` 231 | 232 | See [the documentation](https://python-ranges.readthedocs.io/en/latest/#ranges.RangeSet) for more details. 233 | 234 | ### ` RangeDict` 235 | 236 | This data structure is analagous to python's built-in `dict` data structure, except 237 | it uses `Range`s/`RangeSet`s as keys. As shown above, you can use `RangeDict` to 238 | concisely express different behavior depending on which range a value falls into. 239 | 240 | To make a `RangeDict`, call `RangeDict()` with an either a `dict` or an iterable 241 | of 2-tuples corresponding `Range`s or `RangeSet`s with values. You can also use 242 | a tuple of `Range`s as a key. 243 | A `RangeDict` can handle any type of `Range`, or even multiple different types of 244 | `Range`s all at once: 245 | 246 | ```python 247 | advisors = RangeDict([ 248 | (Range(end="I"), "Gilliam"), 249 | (Range("I", "Q"), "Jones"), 250 | (Range(start="Q"), "Chapman"), 251 | ]) 252 | 253 | mixmatch = RangeDict({ 254 | (Range(0, 8), Range("A", "I")): "Gilliam", 255 | (Range(8, 16), Range("I", "Q")): "Jones", 256 | (Range(start=16), Range(start="Q")): "Chapman", 257 | }) 258 | ``` 259 | 260 | See [the documentation](https://python-ranges.readthedocs.io/en/latest/#ranges.RangeDict) for more details. 261 | 262 | ## Support / Contributing 263 | 264 | If you spot any bugs in this module, please 265 | [submit an issue](https://github.com/Superbird11/ranges/issues) 266 | detailing what you 267 | did, what you were expecting, and what you saw, and I'll make a prompt effort 268 | to isolate the root cause and fix it. The error should be reproducible. 269 | 270 | If, looking through the code, you spot any other improvements that could be 271 | made, then feel free to submit issues detailing those as well. Also feel free 272 | to submit a pull request with improvements to the code. 273 | 274 | This module is extensively unit-tested. All code contributions should be 275 | accompanied by thorough unit tests for every conceivable use case of the new 276 | functionality. If you spot any use cases that aren't currently covered by the 277 | unit test suite, feel free to either 278 | [submit a GitHub issue](https://github.com/Superbird11/ranges/issues) 279 | detailing them, or 280 | simply add them yourself and submit a pull request. 281 | 282 | ### Possible To-Do List: 283 | 284 | - Add a notion of a `PermissiveRangeSet` (name pending) which allows multiple types 285 | of `Range`s that are not necessarily mutually comparable. In the initial design I 286 | considered a number of ways to implement this, but ran into conceptual difficulties, 287 | mainly in terms of handling performance and algorithms. If you can build a 288 | `PermissiveRangeSet` or similar class that implements this functionality, along with 289 | a suitable set of unit tests, then feel free to do so and submit a pull request (if 290 | you do, please include the reasoning for your design decisions). 291 | - Rewrite `RangeSet.intersection()` to use a pair-stepping 292 | algorithm (akin to the "merge" part of MergeSort - iterate through the two 293 | `_LinkedList` data structures simultaneously and only advance one element of one 294 | list at a time) instead of the current "compare every element with every other 295 | element" solution. Adding short-circuiting to this (returning early from the method 296 | once it's clear that there is no longer work to be done, even if the entire list 297 | has not yet been iterated through) would also be useful, and the two approaches 298 | synergize nicely. This won't lower the complexity class below its current 299 | worst-case `O(n^2)`, but it could drastically improve performance. 300 | - Rewrite `RangeSet.isdisjoint()` to use pair-stepping and short-circuiting. The 301 | reasoning here is the same as for `RangeSet.intersection()`. 302 | - Rewrite `RangeDict.getitem()` and `RangeDict.getoverlapitems()` to use a binary 303 | search, for efficiency on potentially large dicts. 304 | - Add pretty-printing for `RangeSet` and especially `RangeDict`. The `pprint` 305 | module does not seem to work on them, unfortunately. 306 | - Replace the `_LinkedList` data structure (contained in `_helper.py`) with an 307 | [interval list](https://en.wikipedia.org/wiki/Interval_tree), an 308 | [`O(sqrt(n))` list](https://github.com/igushev/IgushArray), or some other data 309 | structure more tailored to the particular problem. Linked List was chosen because 310 | it supported quick insertion/deletion and was easy to implement; the latter 311 | concern is no longer relevant. 312 | 313 | Any open issues or bugs are also fair game for contribution. See 314 | [above](#errors--contributing) for directions. 315 | 316 | ## License 317 | 318 | [MIT License](LICENSE.txt). Feel free to use `ranges` however you like. 319 | -------------------------------------------------------------------------------- /ranges/_helper.py: -------------------------------------------------------------------------------- 1 | from numbers import Number 2 | from operator import eq # , is_ 3 | from typing import Any, Iterable, Union, TypeVar 4 | 5 | 6 | r""" 7 | Type hint for the specific type of string that can be parsed as a range. 8 | Essentially, must conform to regex `r'[\(\[]\d+(?:\.\.|,)\d+[\)\]]'`, ignoring 9 | all whitespace. In human-readable form, that's `[start..end]`, where both `start` 10 | and `end` should be numbers, either square or round brackets may be used, and either 11 | `..` or `,` may be used as the separator in the middle. 12 | """ 13 | RangelikeString = TypeVar('RangelikeString', bound=str) 14 | 15 | """ Type hint to denote a range-like object - either a Range, RangeSet, or a string that can be parsed as a range """ 16 | Rangelike = Union['Range', 'RangeSet', RangelikeString] 17 | 18 | 19 | def _is_iterable_non_string(arg): 20 | """ 21 | A helper method to return True if the given argument appears to be iterable 22 | (like a list) but not able to be converted to a Range. 23 | 24 | In particular, checks for whether python would consider the argument to be 25 | iterable (it has either __iter__() or __getattr__() defined), and then 26 | checks that it isn't a string (in which case we don't want to iterate through 27 | it, we want to pass it in to Range() wholesale). 28 | """ 29 | return (hasattr(arg, "__iter__") or hasattr(arg, "__getattr__")) and not isinstance(arg, str) 30 | 31 | 32 | class _InfiniteValue(Number): 33 | """ 34 | A class representing positive or negative infinity, mainly as a stand-in for float's version 35 | of infinity, able to represent an infinite value for other types. 36 | """ 37 | def __init__(self, negative=False): 38 | self.negative = negative 39 | self.floatvalue = float('-inf' if self.negative else 'inf') 40 | 41 | def __lt__(self, other): 42 | """ -infinity is always less """ 43 | if isinstance(other, float): 44 | return self.floatvalue < other 45 | else: 46 | return self.negative and not self == other 47 | 48 | def __gt__(self, other): 49 | """ +infinity is always more """ 50 | if isinstance(other, float): 51 | return self.floatvalue > other 52 | else: 53 | return not self.negative and not self == other 54 | 55 | def __le__(self, other): 56 | return self < other or self == other 57 | 58 | def __ge__(self, other): 59 | return self > other or self == other 60 | 61 | def __ne__(self, other): 62 | return not self == other 63 | 64 | def __eq__(self, other): 65 | """ for consistency, infinity is equal to itself """ 66 | if isinstance(other, float): 67 | return self.floatvalue == other 68 | elif isinstance(other, _InfiniteValue): 69 | return self.negative == other.negative 70 | else: 71 | return False 72 | 73 | def __add__(self, other): 74 | return self.floatvalue + other 75 | 76 | def __radd__(self, other): 77 | return other + self.floatvalue 78 | 79 | def __sub__(self, other): 80 | return self.floatvalue - other 81 | 82 | def __rsub__(self, other): 83 | return other - self.floatvalue 84 | 85 | def __mul__(self, other): 86 | return self.floatvalue * other 87 | 88 | def __rmul__(self, other): 89 | return other * self.floatvalue 90 | 91 | def __truediv__(self, other): 92 | return self.floatvalue / other 93 | 94 | def __rtruediv__(self, other): 95 | return other / self.floatvalue 96 | 97 | def __floordiv__(self, other): 98 | return self.floatvalue // other 99 | 100 | def __rfloordiv__(self, other): 101 | return other // self.floatvalue 102 | 103 | def __mod__(self, other): 104 | return self.floatvalue % other 105 | 106 | def __rmod__(self, other): 107 | return other % self.floatvalue 108 | 109 | def __divmod__(self, other): 110 | return divmod(self.floatvalue, other) 111 | 112 | def __rdivmod__(self, other): 113 | return divmod(other, self.floatvalue) 114 | 115 | def __neg__(self): 116 | return _InfiniteValue(negative=not self.negative) 117 | 118 | def __int__(self): 119 | return int(self.floatvalue) 120 | 121 | def __float__(self): 122 | return self.floatvalue 123 | 124 | def __str__(self): 125 | return str(self.floatvalue) 126 | 127 | def __repr__(self): 128 | """ pretend to be float infinity """ 129 | return repr(self.floatvalue) 130 | 131 | def __hash__(self): 132 | """ pretend to be float infinity """ 133 | return hash(self.floatvalue) 134 | 135 | 136 | Inf = _InfiniteValue() 137 | 138 | 139 | T = TypeVar('T', bound=Any) 140 | 141 | class _LinkedList(Iterable[T]): 142 | """ 143 | A custom definition of a single, feature-poor, linked-list. 144 | """ 145 | class Node: 146 | def __init__(self, value, prev=None, next=None, parent=None): 147 | self.value = value 148 | self.prev = prev 149 | self.next = next 150 | self.parent = parent 151 | 152 | def __eq__(self, other): 153 | return self.value.__eq__(other.value) 154 | 155 | def __lt__(self, other): 156 | return self.value.__lt__(other.value) 157 | 158 | def __gt__(self, other): 159 | return self.value.__gt__(other.value) 160 | 161 | def __ge__(self, other): 162 | return self.value.__ge__(other.value) 163 | 164 | def __le__(self, other): 165 | return self.value.__le__(other.value) 166 | 167 | def __str__(self): 168 | return f"Node({str(self.value)})" 169 | 170 | def __repr__(self): 171 | return str(self) 172 | 173 | def __init__(self, iterable=None): 174 | """ 175 | Constructs a new Linked List based on the given iterable 176 | """ 177 | if iterable is None: 178 | iterable = [] 179 | self._length = 0 180 | if len(iterable) == 0: 181 | self.first = None 182 | self.last = None 183 | else: 184 | # iterate through iterable 185 | it = iter(iterable) 186 | # first element of iterable becomes self.first 187 | self.first = self.Node(next(it), parent=self) 188 | self.last = self.first 189 | self._length += 1 190 | # populate rest of list as long as iterable remains 191 | for elem in it: 192 | self.last.next = self.Node(elem, prev=self.last, parent=self) 193 | self.last = self.last.next 194 | self._length += 1 195 | # that should be all 196 | 197 | def node_at(self, index): 198 | if index < 0: 199 | index = self._length + index 200 | if index >= self._length: 201 | raise IndexError(f"List index {index} out of range") 202 | elif index <= self._length // 2: 203 | cur = self.first 204 | for _ in range(index): 205 | cur = cur.next 206 | return cur 207 | else: 208 | cur = self.last 209 | for _ in range(self._length - index - 1): 210 | cur = cur.prev 211 | return cur 212 | 213 | def _insert_first(self, value): 214 | self.first = self.Node(value, parent=self) 215 | self.last = self.first 216 | self._length += 1 217 | 218 | def prepend(self, value): 219 | if self._length == 0: 220 | self._insert_first(value) 221 | else: 222 | self.first.prev = self.Node(value, next=self.first, parent=self) 223 | self.first = self.first.prev 224 | self._length += 1 225 | 226 | def append(self, value): 227 | if self._length == 0: 228 | self._insert_first(value) 229 | else: 230 | self.last.next = self.Node(value, prev=self.last, parent=self) 231 | self.last = self.last.next 232 | self._length += 1 233 | 234 | def insert_before(self, node, value): 235 | if node.parent != self: 236 | raise ValueError("Given node does not belong to this list") 237 | elif node == self.first: 238 | self.prepend(value) 239 | else: 240 | node.prev.next = self.Node(value, prev=node.prev, next=node, parent=self) 241 | node.prev = node.prev.next 242 | self._length += 1 243 | 244 | def insert_after(self, node, value): 245 | if node.parent != self: 246 | raise ValueError("Given node does not belong to this list") 247 | elif node == self.last: 248 | self.append(value) 249 | else: 250 | node.next.prev = self.Node(value, prev=node, next=node.next, parent=self) 251 | node.next = node.next.prev 252 | self._length += 1 253 | 254 | def insert(self, index, value): 255 | # accommodate negative indices 256 | if index < 0: 257 | index = self._length + index 258 | # account for front/back of list 259 | if index == 0: 260 | self.prepend(value) 261 | elif index == self._length: 262 | self.append(value) 263 | # count from front or back of list, depending on which is closer 264 | else: 265 | self.insert_after(self.node_at(index), value) 266 | 267 | def pop_node(self, node): 268 | if not (node.parent is self): 269 | raise ValueError("Given node does not belong to this list") 270 | if node is self.first: 271 | return self.pop(0) 272 | elif node is self.last: 273 | return self.pop() 274 | else: 275 | node.prev.next = node.next 276 | node.next.prev = node.prev 277 | node.parent = None 278 | self._length -= 1 279 | return node.value 280 | 281 | def pop_after(self, node): 282 | if node.parent != self: 283 | raise ValueError("Given node does not belong to this list") 284 | if node is self.last: 285 | raise IndexError("Can't pop after last node") 286 | return self.pop_node(node.next) 287 | 288 | def pop_before(self, node): 289 | if node.parent != self: 290 | raise ValueError("Given node does not belong to this list") 291 | if node is self.first: 292 | raise IndexError("Can't pop before first node") 293 | return self.pop_node(node.prev) 294 | 295 | def pop(self, index=None): 296 | # accommodate negative indices 297 | if index is not None and index < 0: 298 | index = self._length + index 299 | # pop only element 300 | if self.first is self.last: 301 | if index: # shorthand for checking if index in [None, 0] - just check if it's falsey 302 | raise IndexError(f"List index {index} out of range") 303 | else: 304 | self.first = None 305 | self.last = None 306 | self._length -= 1 307 | # pop at end 308 | elif index is None or index == self._length - 1: 309 | temp = self.last 310 | self.last.parent = None 311 | self.last.prev.next = self.last.next 312 | self.last = self.last.prev 313 | self._length -= 1 314 | return temp.value 315 | # pop from start 316 | elif index == 0: 317 | temp = self.first 318 | self.first.parent = None 319 | self.first.next.prev = self.first.prev 320 | self.first = self.first.next 321 | self._length -= 1 322 | return temp.value 323 | # otherwise, find index and pop it 324 | else: 325 | return self.pop_node(self.node_at(index)) 326 | 327 | def remove(self, value): 328 | node = self.find_node(value) 329 | if node: 330 | self.pop_node(node) 331 | else: 332 | raise ValueError(f"{value} is not in list") 333 | 334 | def clear(self): 335 | self.first = None 336 | self.last = None 337 | self._length = 0 338 | 339 | def get(self, index): 340 | return self.node_at(index).value 341 | 342 | def set(self, index, value): 343 | self.node_at(index).value = value 344 | 345 | def find_node(self, value): 346 | """ Returns the node that contains the given value """ 347 | cur = self.first 348 | while cur: 349 | if cur.value == value: 350 | return cur 351 | cur = cur.next 352 | return None 353 | 354 | def isempty(self): 355 | return len(self) == 0 356 | 357 | def gnomesort(self): 358 | """ 359 | In-place gnome sort. Very efficient sort for bubbling just one out-of-place element. 360 | Technically insertion sort would be quicker but honestly this is much easier to implement 361 | and is the same complexity class - O(n) for just one element out-of-place 362 | """ 363 | # nothing to do if we're empty or singleton 364 | if len(self) < 2: 365 | return 366 | # start with second element, and always compare to the element before 367 | current = self.first.next 368 | while current is not None: 369 | # thus current must have a .prev 370 | # If this element is unsorted with the element before it, then 371 | if current.prev and current.value < current.prev.value: 372 | # swap this element with the element before it 373 | # using insert_after and pop_before is an easy way to handle first/last identities 374 | self.insert_after(current, self.pop_before(current)) 375 | # and then check the new previous-element. 376 | else: 377 | # advance to next node (or None if this is the last node in the list, in which case we terminate) 378 | current = current.next 379 | 380 | def copy(self): 381 | return _LinkedList(self) 382 | 383 | def __copy__(self): 384 | return self.copy() 385 | 386 | def __getitem__(self, index): 387 | return self.get(index) 388 | 389 | def __setitem__(self, key, value): 390 | self.set(key, value) 391 | 392 | def __delitem__(self, key): 393 | self.pop(key) 394 | 395 | def __iter__(self): 396 | current_node = self.first 397 | while current_node: 398 | yield current_node.value 399 | current_node = current_node.next 400 | 401 | def __reversed__(self): 402 | current_node = self.last 403 | while current_node: 404 | yield current_node.value 405 | current_node = current_node.prev 406 | 407 | def __contains__(self, item): 408 | current_node = self.first 409 | while current_node: 410 | if current_node.value == item: 411 | return True 412 | current_node = current_node.next 413 | return False 414 | 415 | def __add__(self, other): 416 | return _LinkedList(list(iter(self)) + list(iter(other))) 417 | 418 | def __iadd__(self, other): 419 | for elem in other: 420 | self.append(elem) 421 | return self 422 | 423 | def __eq__(self, other): 424 | if not isinstance(other, _LinkedList): 425 | return False 426 | for a, b in zip(self, other): 427 | if a != b: 428 | return False 429 | return True 430 | 431 | def __len__(self): 432 | return self._length 433 | 434 | def __str__(self): 435 | return f"LinkedList{str(list(iter(self)))}" 436 | 437 | 438 | class _Sentinel(object): 439 | pass 440 | 441 | 442 | class _UnhashableFriendlyDict(dict): 443 | def __init__(self, *args, **kwargs): 444 | self._unhashable = [] 445 | super(_UnhashableFriendlyDict, self).__init__() 446 | self.update(*args, **kwargs) 447 | self._operator = eq 448 | 449 | def __setitem__(self, key, value): 450 | try: 451 | super(_UnhashableFriendlyDict, self).__setitem__(key, value) 452 | except TypeError: 453 | for i in range(len(self._unhashable)): 454 | if self._operator(self._unhashable[i][0], key): 455 | del self[self._unhashable[i][2]] 456 | self._unhashable.pop(i) 457 | break 458 | sentinel = _Sentinel() 459 | self._unhashable.append((key, value, sentinel)) 460 | self[sentinel] = sentinel 461 | 462 | def __getitem__(self, item): 463 | try: 464 | return super(_UnhashableFriendlyDict, self).__getitem__(item) 465 | except TypeError: 466 | for k, v, _ in self._unhashable: 467 | if self._operator(k, item): 468 | return v 469 | raise KeyError(item) 470 | 471 | def __delitem__(self, key): 472 | try: 473 | super(_UnhashableFriendlyDict, self).__delitem__(key) 474 | except TypeError: 475 | for i in range(len(self._unhashable)): 476 | if self._operator(self._unhashable[i][0], key): 477 | del self[self._unhashable[i][2]] 478 | self._unhashable.pop(i) 479 | break 480 | 481 | def __contains__(self, item): 482 | try: 483 | return super(_UnhashableFriendlyDict, self).__contains__(item) 484 | except TypeError: 485 | return any(self._operator(item, i[0]) for i in self._unhashable) 486 | 487 | def __eq__(self, other): 488 | if self._unhashable: 489 | return isinstance(other, _UnhashableFriendlyDict) \ 490 | and super(_UnhashableFriendlyDict, self).__eq__(other) and self._unhashable == other.unhashable 491 | return super(_UnhashableFriendlyDict, self).__eq__(other) 492 | 493 | def __iter__(self): 494 | for key in super(_UnhashableFriendlyDict, self).__iter__(): 495 | if isinstance(key, _Sentinel): 496 | for k, _, s in self._unhashable: 497 | if s is key: 498 | yield k 499 | break 500 | else: 501 | yield key 502 | else: 503 | yield key 504 | 505 | def __len__(self): 506 | return super(_UnhashableFriendlyDict, self).__len__() + len(self._unhashable) 507 | 508 | def __reversed__(self): 509 | for key in super(_UnhashableFriendlyDict, self).__reversed__(): 510 | if isinstance(key, _Sentinel): 511 | for k, _, s in self._unhashable: 512 | if s is key: 513 | yield k 514 | break 515 | else: 516 | yield key 517 | else: 518 | yield key 519 | 520 | def __repr__(self): 521 | return f'''{{{ 522 | ', '.join([f'{repr(key)}: {repr(value)}' for key, value in self.items()]) 523 | }}}''' 524 | 525 | def __str__(self): 526 | return repr(self) 527 | 528 | def clear(self): 529 | self._unhashable.clear() 530 | super(_UnhashableFriendlyDict, self).clear() 531 | 532 | def copy(self): 533 | c = _UnhashableFriendlyDict(super(_UnhashableFriendlyDict, self).copy()) 534 | c._unhashable = self._unhashable[:] 535 | 536 | def get(self, key, default=None): 537 | try: 538 | return self[key] 539 | except KeyError: 540 | return default 541 | 542 | def items(self): 543 | for key, value in super(_UnhashableFriendlyDict, self).items(): 544 | if isinstance(key, _Sentinel): 545 | for k, v, s in self._unhashable: 546 | if s is key: 547 | yield (k, v) 548 | break 549 | else: 550 | yield (key, value) 551 | else: 552 | yield (key, value) 553 | 554 | def keys(self): 555 | for key in super(_UnhashableFriendlyDict, self).keys(): 556 | if isinstance(key, _Sentinel): 557 | for k, _, s in self._unhashable: 558 | if s is key: 559 | yield k 560 | break 561 | else: 562 | yield key 563 | else: 564 | yield key 565 | 566 | def pop(self, d): 567 | v = self[d] 568 | del self[d] 569 | return v 570 | 571 | def popitem(self): 572 | k, v = super(_UnhashableFriendlyDict, self).popitem() 573 | if isinstance(k, _Sentinel): 574 | for i in range(len(self._unhashable)): 575 | if self._unhashable[i][2] is k: 576 | nk, nv = self._unhashable[i][0], self._unhashable[i][1] 577 | self._unhashable.pop(i) 578 | del self[self._unhashable[i][2]] 579 | return nk, nv 580 | else: 581 | return k, v 582 | else: 583 | return k, v 584 | 585 | def setdefault(self, key, default=None): 586 | try: 587 | return super(_UnhashableFriendlyDict, self).setdefault(key, default) 588 | except TypeError: 589 | if key not in self: 590 | self[key] = default 591 | return default 592 | return self[key] 593 | 594 | def update(self, *args, **f): 595 | if len(args) > 1: 596 | raise TypeError(f"update expected at most 1 arguments, got {len(args)}") 597 | if len(args) == 1: 598 | e = args[0] 599 | if hasattr(e, 'keys') and callable(e.keys): 600 | for k in e: 601 | self[k] = e[k] 602 | else: 603 | for k, v in e: 604 | self[k] = v 605 | if isinstance(e, _UnhashableFriendlyDict): 606 | self._unhashable.extend(e._unhashable) 607 | for k in f: 608 | self[k] = f[k] 609 | 610 | def values(self): 611 | for value in super(_UnhashableFriendlyDict, self).values(): 612 | if isinstance(value, _Sentinel): 613 | for _, v, s in self._unhashable: 614 | if s is value: 615 | yield v 616 | break 617 | else: 618 | yield value 619 | else: 620 | yield value 621 | 622 | @classmethod 623 | def fromkeys(cls, iterable, value=None): 624 | d = _UnhashableFriendlyDict() 625 | for item in iterable: 626 | d[item] = value 627 | -------------------------------------------------------------------------------- /test/test_Range.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing the Range class 3 | """ 4 | import pytest 5 | from ranges import Range, RangeSet, Inf 6 | import numbers 7 | from decimal import Decimal 8 | import datetime 9 | from .test_base import asserterror 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "start,end,include_start,include_end,isempty", [ 14 | # boundary value 15 | (1, 2, False, False, False), 16 | (0, 8.5, False, False, False), 17 | (-1.3, 0, False, False, False), 18 | (-999, 10000000, False, False, False), 19 | (float('-inf'), float('inf'), False, False, False), 20 | (1.0000000001, 1.0000000002, False, False, False), 21 | # inclusivity 22 | (9, 9, True, True, False), 23 | (2.5, 2.5, True, False, True), 24 | (-72.421642, -72.421642, False, True, True), 25 | (7, 7, False, False, True), 26 | # acceptance 27 | (float('inf'), float('inf'), True, True, False), 28 | (float('-inf'), float('-inf'), True, True, False), 29 | (0.3, 0.1+0.2, False, False, False), # floating point errors tee hee hee 30 | (Decimal(1), Decimal(2), False, False, False), 31 | (Decimal(1)/Decimal(10), 0.1, False, False, False), 32 | ('aardvark', 'pistachio', False, False, False), 33 | ('bongo', 'bongo', False, False, True), 34 | ('bingo', 'bongo', False, False, False), 35 | (datetime.date(2018, 7, 2), datetime.date(2018, 7, 3), False, False, False), 36 | (datetime.date(2018, 7, 2), datetime.date(2018, 7, 2), False, False, True), 37 | (datetime.timedelta(3), datetime.timedelta(4), False, False, False), 38 | (datetime.time(2, 34, 7, 2154), datetime.time(2, 34, 7, 2155), False, False, False) 39 | ] 40 | ) 41 | def test_range_constructor_valid(start, end, include_start, include_end, isempty): 42 | """ 43 | Tests all possible permutations of the Range() constructor to make sure that they produce valid ranges. 44 | Also tests is_empty(). 45 | """ 46 | fakestart = 5 if start == 4 else 4 47 | fakeend = 9 if start == 8 else 8 48 | test_ranges = [ 49 | (Range(start, end), {'check_include_start', 'check_include_end'}), 50 | (Range(start=start, end=end), {'check_include_start', 'check_include_end'}), 51 | (Range(start, end, include_start=include_start), {'check_include_end'}), 52 | (Range(start, end, include_end=include_end), {'check_include_start'}), 53 | (Range(start, end, include_start=include_start, include_end=include_end), {}), 54 | (Range(start=start, end=end, include_start=include_start), {'check_include_end'}), 55 | (Range(start=start, end=end, include_end=include_end), {'check_include_start'}), 56 | (Range(start=start, end=end, include_start=include_start, include_end=include_end), {}), 57 | (Range(start, end, start=fakestart, end=fakeend), {'check_include_start', 'check_include_end'}), 58 | (Range(start, end, start=fakestart, end=fakeend, include_start=include_start), {'check_include_end'}), 59 | (Range(start, end, start=fakestart, end=fakeend, include_end=include_end), {'check_include_start'}), 60 | (Range(start, end, start=fakestart, end=fakeend, include_start=include_start, include_end=include_end), {}), 61 | ] 62 | if isinstance(start, numbers.Number) and isinstance(end, numbers.Number): 63 | test_ranges += [ 64 | (Range(start=start, include_start=include_start), {'infinite_end', 'check_include_end'}), 65 | (Range(start=start, include_end=include_end), {'infinite_end', 'check_include_start'}), 66 | (Range(start=start, include_start=include_start, include_end=include_end), {'infinite_end'}), 67 | (Range(end=end, include_start=include_start), {'infinite_start', 'check_include_end'}), 68 | (Range(end=end, include_end=include_end), {'infinite_start', 'check_include_start'}), 69 | (Range(end=end, include_start=include_start, include_end=include_end), {'infinite_start'}), 70 | (Range(start=start), {'infinite_end', 'check_include_start', 'check_include_end'}), 71 | (Range(end=end), {'infinite_start', 'check_include_start', 'check_include_end'}), 72 | ] 73 | numstr = "" 74 | if (isinstance(start, int) and isinstance(end, int)) or (isinstance(start, float) and isinstance(end, float)): 75 | numstr = f"{'[' if include_start else '('}{start}, {end}{']' if include_end else ')'}" 76 | numstr2 = numstr.replace(", ", "..") 77 | test_ranges += [ 78 | (Range(numstr), {}), 79 | (Range(numstr, start=fakestart), {}), 80 | (Range(numstr, end=fakeend), {}), 81 | (Range(numstr, include_start=not include_start), {}), 82 | (Range(numstr, include_end=not include_end), {}), 83 | (Range(numstr, start=fakestart, end=fakeend), {}), 84 | (Range(numstr, start=fakestart, include_start=not include_start), {}), 85 | (Range(numstr, start=fakestart, include_end=not include_end), {}), 86 | (Range(numstr, end=fakeend, include_start=not include_start), {}), 87 | (Range(numstr, end=fakeend, include_end=not include_end), {}), 88 | (Range(numstr, include_start=not include_start, include_end=not include_end), {}), 89 | (Range(numstr, start=fakestart, end=fakeend, include_start=not include_start), {}), 90 | (Range(numstr, start=fakestart, end=fakeend, include_end=not include_end), {}), 91 | (Range(numstr, start=fakestart, include_start=not include_start, include_end=not include_end), {}), 92 | (Range(numstr, end=fakeend, include_start=not include_start, include_end=not include_end), {}), 93 | (Range(numstr, start=fakestart, end=fakeend, include_start=not include_start, include_end=not include_end), 94 | {}), 95 | (Range(numstr2), {}), 96 | (Range(numstr2, start=fakestart), {}), 97 | (Range(numstr2, end=fakeend), {}), 98 | (Range(numstr2, include_start=not include_start), {}), 99 | (Range(numstr2, include_end=not include_end), {}), 100 | (Range(numstr2, start=fakestart, end=fakeend), {}), 101 | (Range(numstr2, start=fakestart, include_start=not include_start), {}), 102 | (Range(numstr2, start=fakestart, include_end=not include_end), {}), 103 | (Range(numstr2, end=fakeend, include_start=not include_start), {}), 104 | (Range(numstr2, end=fakeend, include_end=not include_end), {}), 105 | (Range(numstr2, include_start=not include_start, include_end=not include_end), {}), 106 | (Range(numstr2, start=fakestart, end=fakeend, include_start=not include_start), {}), 107 | (Range(numstr2, start=fakestart, end=fakeend, include_end=not include_end), {}), 108 | (Range(numstr2, start=fakestart, include_start=not include_start, include_end=not include_end), {}), 109 | (Range(numstr2, end=fakeend, include_start=not include_start, include_end=not include_end), {}), 110 | (Range(numstr2, start=fakestart, end=fakeend, include_start=not include_start, include_end=not include_end), 111 | {}), 112 | ] 113 | for idx, test in enumerate(test_ranges): 114 | # print(idx, test) 115 | assert(test[0].start == start if 'infinite_start' not in test[1] else test[0].start == float('-inf')) 116 | assert(test[0].end == end if 'infinite_end' not in test[1] else test[0].end == float('inf')) 117 | assert(test[0].include_start == include_start 118 | if 'check_include_start' not in test[1] else test[0].include_start) 119 | assert(test[0].include_end == include_end if 'check_include_end' not in test[1] else not test[0].include_end) 120 | if 'check_include_start' not in test[1] and 'check_include_end' not in test[1] \ 121 | and 'infinite_start' not in test[1] and 'infinite_end' not in test[1]: 122 | assert(test[0].isempty() == isempty) 123 | assert(bool(test[0]) != isempty) 124 | if (isinstance(start, int) and isinstance(end, int)) or (isinstance(start, float) and isinstance(end, float)): 125 | assert str(test[0] == numstr) 126 | 127 | 128 | @pytest.mark.parametrize( 129 | "args,kwargs", [ 130 | # invalid types/argument combinations 131 | (([1, 2], 3), {}), 132 | ((1, [2, 3]), {}), 133 | ((1,), {}), 134 | ((1,), {"end": 2}), 135 | ((1,), {"start": 0, "end": 2}), 136 | # bad order 137 | (([2, 1],), {}), 138 | ([datetime.timedelta(3), datetime.timedelta(2)], {}), 139 | (("z", "a"), {}), 140 | (("[2, 1]",), {}), 141 | (("(2, 1)",), {}), 142 | (([2, 1]), {}), 143 | ((), {"start": 2, "end": 1}), 144 | # non-comparable types 145 | ((1, "2"), {}), 146 | ((1, datetime.timedelta(3)), {}), 147 | ((1, datetime.date(3, 2, 1)), {}), 148 | (("1", datetime.date(3, 2, 1)), {}), 149 | # non-compatible types w.r.t. default arguments 150 | # note: this is no longer a valid concern, since _InfiniteValue was implemented 151 | # ((), {"start": datetime.timedelta(3)}), 152 | # ((), {"start": "-inf"}), 153 | # ((), {"end": datetime.date(3, 2, 1)}), 154 | # ((), {"end": "inf"}), 155 | # invalid string formats 156 | (("1, 2",), {}), 157 | (("1",), {}), 158 | (("[]",), {}), 159 | (("",), {}), 160 | (("{1, 2}",), {}), 161 | (("(1 2)",), {}), 162 | (("(one, two)",), {}), 163 | (("[1, 2",), {}), 164 | ] 165 | ) 166 | def test_range_constructor_invalid(args, kwargs): 167 | """ Tests invalid calls to `Range.__init__()`, asserting that they yield the correct error. """ 168 | asserterror(ValueError, Range, args, kwargs) 169 | 170 | 171 | @pytest.mark.parametrize( 172 | "rng, item, contains, strr, reprr", [ 173 | (Range(1, 2), 1, True, "[1, 2)", "Range[1, 2)"), 174 | (Range(1, 2), 2, False, "[1, 2)", "Range[1, 2)"), 175 | (Range(1, 2), 1.5, True, "[1, 2)", "Range[1, 2)"), 176 | (Range("(0.3, 0.4)"), 0.1+0.2, True, "(0.3, 0.4)", "Range(0.3, 0.4)"), 177 | (Range("(0.3, 0.4)"), 0.3, False, "(0.3, 0.4)", "Range(0.3, 0.4)"), 178 | (Range(), 99e99, True, "[-inf, inf)", "Range[-inf, inf)"), 179 | (Range(), -99e99, True, "[-inf, inf)", "Range[-inf, inf)"), 180 | (Range(), float('inf'), False, "[-inf, inf)", "Range[-inf, inf)"), # inclusive Infinity is deliberate 181 | (Range(include_end=True), float('inf'), True, "[-inf, inf]", "Range[-inf, inf]"), # (see IEEE 754) 182 | (Range(-3, 3), Range(1, 2), True, "[-3, 3)", "Range[-3, 3)"), 183 | (Range(-3, 3), Range(1, 3), True, "[-3, 3)", "Range[-3, 3)"), # changed, see issue #4 184 | (Range(-3, 3), Range(-4, 4), False, "[-3, 3)", "Range[-3, 3)"), 185 | (Range(-3, 3), Range(-3, -2), True, "[-3, 3)", "Range[-3, 3)"), 186 | (Range(), float('nan'), False, "[-inf, inf)", "Range[-inf, inf)"), 187 | (Range(datetime.date(2017, 5, 27), datetime.date(2018, 2, 2)), datetime.date(2017, 12, 1), True, 188 | "[2017-05-27, 2018-02-02)", "Range[datetime.date(2017, 5, 27), datetime.date(2018, 2, 2))"), 189 | (Range(datetime.date(2017, 5, 27), datetime.date(2018, 2, 2), include_end=True), datetime.date(2018, 12, 1), 190 | False, "[2017-05-27, 2018-02-02]", "Range[datetime.date(2017, 5, 27), datetime.date(2018, 2, 2)]"), 191 | (Range(datetime.timedelta(0, 3600), datetime.timedelta(0, 7200)), datetime.timedelta(0, 6000), True, 192 | "[1:00:00, 2:00:00)", "Range[datetime.timedelta(seconds=3600), datetime.timedelta(seconds=7200))"), 193 | (Range(datetime.timedelta(1, 1804), datetime.timedelta(3)), datetime.timedelta(1), False, 194 | "[1 day, 0:30:04, 3 days, 0:00:00)", 195 | "Range[datetime.timedelta(days=1, seconds=1804), datetime.timedelta(days=3))"), 196 | (Range("begin", "end"), "middle", False, "[begin, end)", "Range['begin', 'end')"), 197 | (Range("begin", "end"), "cows", True, "[begin, end)", "Range['begin', 'end')"), 198 | # RangeSets 199 | (Range(1, 10), RangeSet(Range(2, 9)), True, "[1, 10)", "Range[1, 10)"), 200 | (Range(1, 10), RangeSet(Range(2, 3), Range(4, 5), Range(6, 7), Range(8, 9)), True, "[1, 10)", "Range[1, 10)"), 201 | (Range(1, 10), RangeSet(Range(0, 11)), False, "[1, 10)", "Range[1, 10)"), 202 | (Range(1, 4), RangeSet(Range(2, 3), Range(5, 6)), False, "[1, 4)", "Range[1, 4)"), 203 | ] 204 | ) 205 | def test_range_contains(rng, item, contains, strr, reprr): 206 | """ 207 | Tests the __contains__, __str__, __repr__, and __hash__ methods of the range. 208 | """ 209 | assert(contains == (item in rng)) 210 | assert(strr == str(rng)) 211 | assert(reprr == repr(rng)) 212 | assert(hash(rng) == hash(rng.copy())) 213 | assert(rng in rng) 214 | 215 | 216 | @pytest.mark.parametrize( 217 | "lesser,greater,equal", [ 218 | (Range(), Range(), True), 219 | (Range(1, 2), Range("[1, 2)"), True), 220 | (Range(1, 2), Range(1, 2, include_end=True), False), 221 | (Range(1, 2, include_start=True), Range(1, 2, include_start=False), False), 222 | (Range(), Range(1, 2), False), 223 | (Range(1, 4), Range(2, 3), False), 224 | (Range(1, 3), Range(2, 4), False), 225 | ] 226 | ) 227 | def test_range_comparisons(lesser, greater, equal): 228 | """ 229 | Tests the comparison operators (<, >, etc.) on the Range class 230 | """ 231 | assert(equal == (lesser == greater)) 232 | assert(equal != (lesser != greater)) 233 | assert(lesser <= greater) 234 | assert(equal != (lesser < greater)) 235 | assert(not (greater < lesser)) 236 | assert(equal != (greater > lesser)) 237 | assert(not (lesser > greater)) 238 | assert(greater >= lesser) 239 | pass 240 | 241 | 242 | @pytest.mark.parametrize( 243 | "rng1,rng2,isdisjoint,error_type", [ 244 | # proper test cases 245 | (Range(1, 3), Range(2, 4), False, None), 246 | (Range(1, 3), Range(4, 6), True, None), 247 | (Range(1, 3), Range(3, 5), True, None), 248 | (Range(1, 3), "[3, 5)", True, "r2"), 249 | (Range(1, 4), Range(2, 3), False, None), 250 | (Range(1, 3, include_end=True), Range(3, 5,), False, None), 251 | (Range(), Range(1, 3), False, None), 252 | # RangeSets 253 | (Range(1, 4), RangeSet(), True, None), 254 | (Range(2, 4), RangeSet(Range(0, 1), Range(5, 6)), True, None), 255 | (Range(2, 4), RangeSet(Range(1, 3)), False, None), 256 | (Range(2, 4), RangeSet(Range(1, 3), Range(5, 6)), False, None), 257 | # errors 258 | (Range(1, 3), Range("apple", "banana"), None, TypeError), 259 | (Range(1, 3), "2, 4", False, TypeError), 260 | (Range(1, 3), 2, False, TypeError), 261 | ] 262 | ) 263 | def test_range_isdisjoint(rng1, rng2, isdisjoint, error_type): 264 | if error_type == "r2": 265 | assert(isdisjoint == rng1.isdisjoint(rng2)) 266 | rng2 = Range(rng2) 267 | error_type = None 268 | if error_type is not None: 269 | asserterror(error_type, rng1.isdisjoint, (rng2,)) 270 | else: 271 | assert(rng1.isdisjoint(rng2) == rng2.isdisjoint(rng1)) 272 | assert(isdisjoint == rng1.isdisjoint(rng2)) 273 | 274 | 275 | @pytest.mark.parametrize( 276 | "rng1,rng2,union,error_type", [ 277 | (Range(1, 3), Range(2, 4), Range(1, 4), None), 278 | (Range(1, 3), "[2, 4)", Range(1, 4), "r2"), 279 | (Range(1, 3), Range(2, 4, include_end=True), Range(1, 4, include_end=True), None), 280 | (Range(1, 3), Range(3, 5), Range(1, 5), None), 281 | (Range(2, 4), Range(1, 3), Range(1, 4), None), 282 | (Range(1, 3), Range(3, 5, include_start=False), None, None), 283 | (Range(1, 3), Range(4, 6), None, None), 284 | (Range(1, 4), Range(2, 3), Range(1, 4), None), 285 | (Range(1, 4), Range(1, 4), Range(1, 4), None), 286 | (Range(1, 4, include_start=False), Range(1, 4, include_end=True), Range(1, 4, include_end=True), None), 287 | (Range(2, 3), RangeSet(Range(1, 2), Range(3, 4)), Range(1, 4), None), # test other stuff in RangeSet.union 288 | # intended errors 289 | (Range(1, 3), Range("apple", "banana"), None, TypeError), 290 | (Range(1, 3), "2, 4", None, TypeError), 291 | (Range(1, 3), 2, None, TypeError), 292 | ] 293 | ) 294 | def test_range_union(rng1, rng2, union, error_type): 295 | if error_type == "r2": 296 | assert(union == rng1.union(rng2)) 297 | rng2 = Range(rng2) 298 | error_type = None 299 | if error_type is not None: 300 | asserterror(error_type, rng1.union, (rng2,)) 301 | else: 302 | assert(rng1.union(rng2) == rng2.union(rng1)) 303 | assert(rng1.union(rng2) == rng1 | rng2) 304 | assert(union == rng1.union(rng2)) 305 | 306 | 307 | @pytest.mark.parametrize( 308 | "rng1,rng2,intersect,error_type", [ 309 | (Range(1, 3), Range(2, 4), Range(2, 3, include_end=False), None), 310 | (Range(1, 3), "[2, 4)", Range(2, 3, include_end=False), "r2"), 311 | (Range(1, 3, include_end=True), Range(2, 4), Range(2, 3, include_end=True), None), 312 | (Range(1, 2), Range(2, 3), None, None), # Behavior changed: issue #7 313 | (Range(1, 3), Range(1, 3), Range(1, 3), None), 314 | (Range(1, 4), Range(2, 3), Range(2, 3), None), 315 | (Range(1, 3), Range(3, 5, include_start=False), None, None), 316 | (Range(1, 2), Range(3, 4), None, None), 317 | (Range(1, 4), RangeSet(Range(3, 6)), Range(3, 4), None), 318 | # intended errors 319 | (Range(1, 3), Range("apple", "banana"), None, TypeError), 320 | (Range(1, 3), "2, 4", None, TypeError), 321 | (Range(1, 3), 2, None, TypeError), 322 | ] 323 | ) 324 | def test_range_intersect(rng1, rng2, intersect, error_type): 325 | if error_type == "r2": 326 | assert(intersect == rng1.intersection(rng2)) 327 | rng2 = Range(rng2) 328 | error_type = None 329 | if error_type is not None: 330 | asserterror(error_type, rng1.intersection, (rng2,)) 331 | else: 332 | assert(rng1.intersection(rng2) == rng2.intersection(rng1)) 333 | assert(rng1 & rng2 == rng1.intersection(rng2)) 334 | assert(intersect == rng1.intersection(rng2)) 335 | 336 | 337 | @pytest.mark.parametrize( 338 | "rng1,rng2,forward_diff,backward_diff,error_type", [ 339 | (Range(1, 3), Range(2, 4), Range(1, 2), Range(3, 4), None), 340 | (Range(1, 3), "[2, 4)", Range(1, 2), Range(3, 4), "r2"), 341 | (Range(1, 3), Range(2, 3), Range(1, 2), None, None), 342 | (Range(1, 3), Range(1, 2), Range(2, 3), None, None), 343 | (Range(1, 3), Range(2, 3), Range(1, 2), None, None), 344 | (Range(1, 3), Range(4, 6), Range(1, 3), Range(4, 6), None), 345 | (Range(1, 3), Range(1, 3), None, None, None), 346 | (Range(1, 3, include_end=True), Range(2, 4), Range(1, 2), Range(3, 4, include_start=False), None), 347 | (Range(1, 4), Range(2, 3), RangeSet((Range(1, 2), Range(3, 4))), None, None), 348 | (Range(1, 3), RangeSet(Range(2, 4)), RangeSet(Range(1, 2)), Range(3, 4), None), 349 | (Range(1, 3), Range(2, 2), RangeSet(Range(1, 3)), None, None), 350 | # intended errors 351 | (Range(1, 3), Range("apple", "banana"), None, None, TypeError), 352 | (Range(1, 3), "2, 4", None, None, TypeError), 353 | (Range(1, 3), 2, None, None, TypeError), 354 | ] 355 | ) 356 | def test_range_difference(rng1, rng2, forward_diff, backward_diff, error_type): 357 | if error_type == "r2": 358 | assert(rng1.difference(rng2) == rng1 - rng2) 359 | rng2 = Range(rng2) 360 | error_type = None 361 | if error_type is not None: 362 | asserterror(error_type, rng1.difference, (rng2,)) 363 | else: 364 | assert(rng1.difference(rng2) == rng1 - rng2) 365 | assert(rng2.difference(rng1) == rng2 - rng1) 366 | assert(forward_diff == rng1.difference(rng2)) 367 | assert(backward_diff == rng2.difference(rng1)) 368 | 369 | 370 | @pytest.mark.parametrize( 371 | "rng1,rng2,symdiff,error_type", [ 372 | (Range(1, 3), Range(2, 4), RangeSet(Range(1, 2), Range(3, 4)), None), # standard overlapping 373 | (Range(1, 2), Range(3, 4), RangeSet(Range(1, 2), Range(3, 4)), None), # totally disjoint 374 | (Range(1, 4), Range(2, 3), RangeSet(Range(1, 2), Range(3, 4)), None), # one contains the other 375 | (Range(1, 4), Range(2, 4), Range(1, 2), None), # single range 376 | (Range(1, 4), Range(1, 4), None, None), # exactly the same, no symmetric difference 377 | (Range("[1..4]"), Range("(1..4)"), RangeSet(Range("[1..1]"), Range("[4..4]")), None), # single-point ranges 378 | (Range(1, 3), RangeSet(Range(2, 4)), RangeSet(Range(1, 2), Range(3, 4)), None), # basic RangeSet 379 | # intended errors 380 | (Range(1, 3), Range("apple", "banana"), None, TypeError), 381 | (Range(1, 3), "2, 4", None, TypeError), 382 | (Range(1, 3), 2, None, TypeError), 383 | ] 384 | ) 385 | def test_range_symmetric_difference(rng1, rng2, symdiff, error_type): 386 | if error_type is not None: 387 | asserterror(error_type, rng1.symmetric_difference, (rng2,)) 388 | else: 389 | assert(rng1.symmetric_difference(rng2) == rng2.symmetric_difference(rng1)) 390 | assert(rng1.symmetric_difference(rng2) == rng1 ^ rng2) 391 | assert(symdiff == rng1.symmetric_difference(rng2)) 392 | 393 | 394 | @pytest.mark.parametrize( 395 | "rng,length,error_type", [ 396 | # regular numbers 397 | (Range(1, 2), 1, None), 398 | (Range(-2, 2), 4, None), 399 | (Range(3, 4.5), 1.5, None), 400 | (Range(3.25, 4.75), 1.5, None), 401 | (Range(0.1, 0.2), 0.1, None), 402 | (Range(), float('inf'), None), 403 | (Range(start=9e99), float('inf'), None), 404 | (Range(end=-9e99), float('inf'), None), 405 | # irregular but comparable arguments 406 | (Range(Decimal(1), Decimal(4)), Decimal(3), None), 407 | (Range(Decimal(1.5), Decimal(4.75)), Decimal(3.25), None), 408 | (Range(datetime.date(2018, 4, 1), datetime.date(2018, 4, 16)), datetime.timedelta(days=15), None), 409 | (Range(datetime.timedelta(seconds=82800), datetime.timedelta(days=1, seconds=3600)), 410 | datetime.timedelta(seconds=7200), None), 411 | # type coercion 412 | (Range(1, Decimal(4.5)), 3.5, None), 413 | (Range(1.5, Decimal(4.5)), 3, None), 414 | (Range(Decimal(1.5), 4), 2.5, None), 415 | (Range(Decimal(1.5), 4.75), 3.25, None), 416 | # errors 417 | (Range('apple', 'banana'), 0, TypeError), 418 | ] 419 | ) 420 | def test_range_length(rng, length, error_type): 421 | if error_type is not None: 422 | asserterror(error_type, rng.length, ()) 423 | else: 424 | assert(length == rng.length()) 425 | 426 | 427 | @pytest.mark.parametrize( 428 | "rng,expected", [ 429 | (Range(), RangeSet()), # complement of an infinite range is a range with no elements 430 | (Range('(5..5)'), RangeSet(Range())), # complement of an empty range is an infinite range 431 | (Range('(5..5]'), RangeSet(Range())), 432 | (Range('[5..5)'), RangeSet(Range())), 433 | (Range('[5..5]'), RangeSet(Range(end=5), Range(start=5, include_start=False))), # complement of single point 434 | (Range(1, 3), RangeSet(Range(end=1), Range(start=3))), # complement of normal range 435 | (Range('[2..3]'), RangeSet(Range('[-inf, 2)'), Range('(3, inf)'))), # complement of normal range, bounds-check 436 | (Range('(2..3)'), RangeSet(Range('[-inf, 2]'), Range('[3, inf)'))), 437 | (Range(end=-1), RangeSet(Range(start=-1))), # complement of one-side-infinite range 438 | (Range(end=-1, include_end=True), RangeSet(Range(start=-1, include_start=False))), 439 | (Range(start=1, include_start=False), RangeSet(Range(end=1, include_end=True))), 440 | (Range(start=1), RangeSet(Range(end=1))), 441 | (Range('inquisition', 'spanish'), RangeSet(Range(end='inquisition'), Range(start='spanish'))), # non-numeric 442 | (Range('e', 'e', include_start=False), RangeSet(Range())), 443 | ] 444 | ) 445 | def test_range_complement(rng, expected): 446 | assert(expected == rng.complement()) 447 | assert(expected == ~rng) 448 | 449 | 450 | @pytest.mark.parametrize( 451 | "rng,value,expected,error_type", [ 452 | # normal tests and boundary-value tests 453 | (Range(1, 5), 3, 3, None), 454 | (Range(1, 5), 1, 1, None), 455 | (Range(1, 5), 5, 5, None), 456 | (Range(1, 5), 0, 1, None), 457 | (Range(1, 5), 6, 5, None), 458 | (Range(1, 5), -Inf, 1, None), 459 | (Range(1, 5), Inf, 5, None), 460 | (Range('c', 'f'), 'depo', 'depo', None), 461 | (Range('c', 'f'), 'caesium', 'caesium', None), 462 | (Range('c', 'f'), 'frozen', 'f', None), 463 | (Range('c', 'f'), 'b', 'c', None), 464 | # infinite range tests 465 | (Range(), 9173, 9173, None), 466 | (Range(), 'apples', 'apples', None), 467 | (Range(), None, None, None), # the infinity object is great, it doesn't error with non-comparable types 468 | (Range(), Inf, Inf, None), # non-comparable type (None) 469 | # error tests 470 | (Range(1, 5), 'apple', None, TypeError), # non-compatible type (int vs string) 471 | (Range(end=1), None, None, TypeError), # adding 1 makes it non-comparable despite being infinite 472 | ] 473 | ) 474 | def test_range_clamp(rng, value, expected, error_type): 475 | if error_type is not None: 476 | asserterror(error_type, rng.clamp, (value,)) 477 | else: 478 | assert(expected == rng.clamp(value)) 479 | 480 | 481 | def test_range_docstring(): 482 | a = Range() 483 | b = Range() 484 | assert(a == b) 485 | assert(str(a) == "[-inf, inf)") 486 | 487 | c = Range("(-3, 5.5)") 488 | d = Range("[-3, 5.5)") 489 | assert(c != d) 490 | assert(c > d) 491 | assert(c.start == d.start and c.end == d.end) 492 | assert(c.start == -3 and c.end == 5.5) 493 | 494 | e = Range(3, 5) 495 | f = Range(3, 5, include_start=False, include_end=True) 496 | assert(e != f) 497 | assert(e < f) 498 | assert(str(e) == "[3, 5)") 499 | assert(str(f) == "(3, 5]") 500 | assert(e.start == f.start and e.end == f.end) 501 | 502 | g = Range(start=3, end=5) 503 | h = Range(start=3) 504 | i = Range(end=5) 505 | j = Range(start=3, end=5, include_start=False, include_end=True) 506 | k = Range(start=datetime.date(1969, 10, 5)) 507 | l = Range(end="ni", include_end=True) 508 | assert(str(g) == "[3, 5)") 509 | assert(str(h) == "[3, inf)") 510 | assert(str(i) == "[-inf, 5)") 511 | assert(str(j) == "(3, 5]") 512 | assert(str(k) == "[1969-10-05, inf)") 513 | assert(repr(k) == "Range[datetime.date(1969, 10, 5), inf)") 514 | assert(str(l) == "[-inf, ni]") 515 | assert(repr(l) == "Range[-inf, 'ni']") 516 | 517 | m = Range(datetime.date(1478, 11, 1), datetime.date(1834, 7, 15)) 518 | assert(datetime.date(1492, 8, 3) in m) 519 | assert(datetime.date(1979, 8, 17) not in m) 520 | 521 | n = Range("killer", "rabbit") 522 | assert("grenade" not in n) 523 | assert("pin" in n) 524 | assert("three" not in n) 525 | 526 | o = Range(include_end=True) 527 | assert(str(o) == "[-inf, inf]") 528 | assert(0 in o) 529 | assert(1 in o) 530 | assert(-99e99 in o) 531 | assert("one" in o) 532 | assert(datetime.date(1975, 3, 14) in o) 533 | assert(None in o) 534 | assert(float('nan') not in o) 535 | 536 | r = Range(include_start=True, include_end=False) 537 | assert(str(r) == "[-inf, inf)") 538 | assert(float('-inf') in r) 539 | assert(float('inf') not in r) 540 | -------------------------------------------------------------------------------- /ranges/RangeSet.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from .Range import Range 3 | from ._helper import _is_iterable_non_string, _LinkedList, Inf, Rangelike 4 | from typing import TypeVar, Iterable, Iterator, Union, Any, List 5 | 6 | 7 | T = TypeVar('T', bound=Any) 8 | 9 | 10 | class RangeSet(Iterable): 11 | """ 12 | A class to represent a mutable set of Ranges that may or may not overlap. 13 | A RangeSet will generally interface cleanly with a Range or another 14 | RangeSet. In general, most methods called on a RangeSet will try to 15 | coerce its argument to a RangeSet if it isn't one already (so, a 16 | list of Ranges is a valid argument for many methods). 17 | 18 | A RangeSet can be constructed from any number of Range-like objects 19 | or iterables containing Range-like objects, all given as positional 20 | arguments. Any iterables will be flattened by one later before having 21 | their contents added to this RangeSet. 22 | 23 | Examples of valid RangeSets include: 24 | 25 | >>> a = RangeSet() # empty RangeSet 26 | >>> b = RangeSet([Range(0, 1), Range(2, 3), Range(4, 5)]) # single iterable as a positional argument 27 | >>> c = RangeSet(Range(0, 1), Range(2, 3), Range(4, 5)) # multiple Ranges as positional arguments 28 | >>> d = RangeSet("[0, 1)", ["[1.5, 2)", "[2.5, 3)"], "[4, 5]") # multiple positional arguments, one is an iterable 29 | 30 | Nested iterables are not supported, and will raise a ValueError: 31 | 32 | >>> e = RangeSet([[Range(0, 1), Range(2, 3)], [Range(4, 5), Range(6, 7)]]) 33 | 34 | Internally, Ranges are stored ordered from least to greatest. 35 | Overlapping ranges will be combined into a single Range. For example: 36 | 37 | >>> f = RangeSet("[0, 3]", "[2, 4)", "[5, 6]") 38 | >>> str(f) == "{[0, 4), [5, 6]}" 39 | 40 | All Ranges in a given RangeSet must be comparable to each other, or 41 | else errors may occur. The Range type is by default comparable with 42 | itself, but this functions by comparing the start and end values - 43 | if two Ranges have non-mutually-comparable start/end values, then 44 | they cannot be compared, which breaks this data structure's internal 45 | organization. This is an intentional design decision. 46 | 47 | RangeSets are hashable, meaning they can be used as keys in dicts. 48 | """ 49 | def __init__(self, *args: Union[Rangelike, Iterable[Rangelike]]): 50 | """ 51 | Constructs a new RangeSet containing the given sub-ranges. 52 | :param args: For each positional argument, if the argument is Rangelike, it is added to this RangeSet, 53 | or if it is an iterable containing Rangelikes, all contained Rangelikes are added to this RangeSet. 54 | """ 55 | # flatten args 56 | temp_list = [] 57 | for arg in args: 58 | if _is_iterable_non_string(arg): 59 | temp_list.extend(Range(x) for x in arg) 60 | else: 61 | temp_list.append(Range(arg)) 62 | # assign own Ranges 63 | self._ranges = RangeSet._merge_ranges(temp_list) 64 | 65 | def add(self, rng: Rangelike) -> None: 66 | """ 67 | Adds a copy of the given range or RangeSet to this RangeSet. 68 | 69 | If the argument is not Range-like and is not a RangeSet, then 70 | a `ValueError` will be raised. If the argument is not comparable 71 | to other Ranges contained in this RangeSet, then a `TypeError` 72 | will be raised. If an iterable is given as the 73 | argument, it will be submitted to the `Range()` constructor. 74 | 75 | To add all ranges in an iterable containing multiple ranges, 76 | use `.extend()` instead. 77 | 78 | :param rng: A single Rangelike object to add to this RangeSet 79 | """ 80 | # if it's a RangeSet, then do extend instead 81 | if isinstance(rng, RangeSet): 82 | self.extend(rng) 83 | return 84 | elif _is_iterable_non_string(rng): 85 | raise ValueError("argument is iterable and not Range-like; use .extend() instead") 86 | # otherwise, convert Range to a list at first 87 | rng = Range(rng) 88 | # change the error message if necessary 89 | try: 90 | temp_ranges = self._ranges.copy() 91 | # if the list of ranges is empty, then add the node at the beginning 92 | if len(temp_ranges) == 0: 93 | temp_ranges.append(rng) 94 | inserted_node = temp_ranges.first 95 | # otherwise, if our range would fit at the end, then put it there 96 | elif rng > temp_ranges.last.value: 97 | temp_ranges.append(rng) 98 | inserted_node = temp_ranges.last 99 | # otherwise, find the node *before which* our range fits 100 | else: 101 | node = temp_ranges.first 102 | while rng > node.value: 103 | node = node.next 104 | temp_ranges.insert_before(node, rng) 105 | inserted_node = node.prev 106 | # now, merge this range with the previous range(s): 107 | if inserted_node.prev: 108 | prev_union = inserted_node.value.union(inserted_node.prev.value) 109 | while prev_union and inserted_node.prev: 110 | inserted_node.value = prev_union 111 | temp_ranges.pop_before(inserted_node) 112 | prev_union = inserted_node.value.union(inserted_node.prev.value) if inserted_node.prev else None 113 | # merge this range with the next range(s) 114 | if inserted_node.next: 115 | next_union = inserted_node.value.union(inserted_node.next.value) 116 | while next_union and inserted_node.next: 117 | inserted_node.value = next_union 118 | temp_ranges.pop_after(inserted_node) 119 | next_union = inserted_node.value.union(inserted_node.next.value) if inserted_node.next else None 120 | except TypeError: 121 | raise TypeError(f"Range '{rng}' is not comparable with the other Ranges in this RangeSet") 122 | # apply changes 123 | self._ranges = temp_ranges 124 | # TODO python 3.8 update - use an assignment operator (see the following code): 125 | # while inserted_node.prev and (prev_union := inserted_node.value.union(inserted_node.prev.value)): 126 | # inserted_node.value = prev_union 127 | # self._ranges.pop_before(inserted_node) 128 | # while inserted_node.next and (next_union := inserted_node.value.union(inserted_node.next.value)): 129 | # inserted_node.value = next_union 130 | # self._ranges.pop_after(inserted_node) 131 | 132 | def extend(self, iterable: Iterable[Rangelike]) -> None: 133 | """ 134 | Adds a copy of each Range or RangeSet in the given iterable to 135 | this RangeSet. 136 | 137 | Raises a `TypeError` if the argument is not iterable. 138 | 139 | This method works identically to `.add()` for RangeSets only. 140 | 141 | :param iterable: iterable containing Rangelike objects to add to this RangeSet 142 | """ 143 | self._ranges = RangeSet._merge_ranges( 144 | self._ranges + (Range(r) for r in iterable) 145 | ) 146 | 147 | def discard(self, rng: Rangelike) -> None: 148 | """ 149 | Removes the entire contents of the given RangeSet from this RangeSet. 150 | 151 | This method will only remove a single RangeSet (or Range) from this 152 | RangeSet at a time. To remove a list of Range-like objects from this 153 | RangeSet, use .difference_update() instead. 154 | 155 | :param rng: Rangelike to remove from this RangeSet. 156 | """ 157 | # be lazy and do O(n^2) erasure 158 | if isinstance(rng, RangeSet): 159 | temp = self.copy() 160 | for r in rng: 161 | temp.discard(r) 162 | self._ranges = temp._ranges 163 | return 164 | # elif _is_iterable_non_string(rng): 165 | # raise ValueError("argument is iterable and not range-like. Use .difference_update() instead") 166 | # make sure rng is a Range 167 | rng = Range(rng) 168 | # remove rng from our ranges until we no longer need to 169 | current_node = self._ranges.first 170 | while current_node: 171 | new_range = current_node.value.difference(rng) 172 | if not new_range or new_range.isempty(): 173 | # first node is entirely consumed by the range to remove. So remove it. 174 | self._ranges.pop_node(current_node) 175 | elif isinstance(new_range, RangeSet): 176 | # replace current value with lower, and add higher just afterwards. 177 | # It can't possibly overlap with the next range, because they are disjoint. 178 | current_node.value = new_range._ranges.first.value 179 | self._ranges.insert_after(current_node, new_range._ranges.last.value) 180 | # in this case, we also know that we just hit the top of the discarding range. 181 | # therefore, we can short-circuit. 182 | break 183 | else: 184 | # replace just this element, which was cut off 185 | if new_range > current_node.value: 186 | # we're only computing the difference of one contiguous range. 187 | # if all we've done is cut off the bottom part of this range, then 188 | # we must have reached the top of the discarding range. 189 | # therefore, we can short-circuit. 190 | current_node.value = new_range 191 | break 192 | else: 193 | # otherwise, we just change this element (maybe replace it with itself) and keep going. 194 | current_node.value = new_range 195 | current_node = current_node.next 196 | 197 | def difference(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 198 | """ 199 | Return a new RangeSet containing the ranges that are in this RangeSet 200 | but not in the other given RangeSet or list of RangeSets. This 201 | RangeSet is not modified in the process. 202 | 203 | :param rng_set: A rangelike object to take difference with, or an iterable of Rangelike objects 204 | to take difference with all of which. 205 | :return: a RangeSet identical to this one except with the given argument removed from it. 206 | """ 207 | new_rng_set = self.copy() 208 | new_rng_set.difference_update(RangeSet(rng_set)) 209 | return new_rng_set 210 | 211 | def difference_update(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> None: 212 | """ 213 | Removes all ranges in the given iterable from this RangeSet. 214 | 215 | If an error occurs while trying to do this, then this RangeSet 216 | remains unchanged. 217 | 218 | :param rng_set: A rangelike object to take difference with, or an iterable of Rangelike objects 219 | to take difference with all of which. 220 | """ 221 | self.discard(RangeSet._to_rangeset(rng_set)) 222 | 223 | def intersection(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 224 | """ 225 | Returns a new RangeSet containing the intersection between this 226 | RangeSet and the given Range or RangeSet - that is, containing 227 | only the elements shared between this RangeSet and the given 228 | RangeSet. 229 | 230 | :param rng_set: A rangelike, or iterable containing rangelikes, to find intersection with 231 | :return: a RangeSet identical to this one except with all values not overlapping the given argument removed. 232 | """ 233 | # convert to a RangeSet 234 | rng_set = RangeSet._to_rangeset(rng_set) 235 | # do O(n^2) difference algorithm 236 | # TODO rewrite to increase efficiency by short-circuiting 237 | intersections = [rng1.intersection(rng2) for rng1 in self._ranges for rng2 in rng_set._ranges] 238 | intersections = [rng for rng in intersections if rng is not None and not rng.isempty()] 239 | return RangeSet(intersections) 240 | 241 | def intersection_update(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> None: 242 | """ 243 | Updates this RangeSet to contain only the intersections between 244 | this RangeSet and the given Range or RangeSet, removing the parts 245 | of this RangeSet's ranges that do not overlap the given RangeSet 246 | 247 | :param rng_set: A rangelike, or iterable containing rangelikes, to find intersection with 248 | """ 249 | self._ranges = self.intersection(rng_set)._ranges 250 | 251 | def union(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 252 | """ 253 | Returns a new RangeSet containing the overlap between this RangeSet and the 254 | given RangeSet 255 | 256 | :param rng_set: A rangelike, or iterable of rangelikes, to find union with 257 | :return: a RangeSet identical to this one but also including all elements in the given argument. 258 | """ 259 | # convert to RangeSet 260 | rng_set = RangeSet._to_rangeset(rng_set) 261 | # simply merge lists 262 | return RangeSet(self._ranges + rng_set._ranges) 263 | 264 | def update(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> None: 265 | """ 266 | Updates this RangeSet to add all the ranges in the given RangeSet, so 267 | that this RangeSet now contains the union of its old self and the 268 | given RangeSet. 269 | 270 | :param rng_set: A rangelike, or iterable of rangelikes, to find union with 271 | """ 272 | # convert to RangeSet 273 | rng_set = RangeSet._to_rangeset(rng_set) 274 | # merge lists 275 | self._ranges = RangeSet._merge_ranges(self._ranges + rng_set._ranges) 276 | 277 | def symmetric_difference(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 278 | """ 279 | Returns a new RangeSet containing the symmetric difference between this 280 | RangeSet and the given Range or RangeSet - everything contained by 281 | this RangeSet or the given RangeSet, but not both. 282 | 283 | :param rng_set: A rangelike, or iterable of rangelikes, to find symmetric difference with 284 | :return: A RangeSet containing all values in either this or the given argument, but not both 285 | """ 286 | # convert to a RangeSet 287 | rng_set = RangeSet._to_rangeset(rng_set) 288 | # get union and then remove intersections 289 | union = self.union(rng_set) 290 | intersection = self.intersection(rng_set) 291 | union.difference_update(intersection) 292 | return union 293 | 294 | def symmetric_difference_update(self, rng_set: Union[Rangelike, Iterable[Rangelike]]) -> None: 295 | """ 296 | Update this RangeSet to contain the symmetric difference between it and 297 | the given Range or RangeSet, by removing the parts of the given RangeSet 298 | that overlap with this RangeSet from this RangeSet. 299 | 300 | :param rng_set: A rangelike, or iterable of rangelikes, to find symmetric difference with 301 | """ 302 | # the easiest way to do this is just to do regular symmetric_difference and then copy the result 303 | rng_set = RangeSet._to_rangeset(rng_set) 304 | self._ranges = self.symmetric_difference(rng_set)._ranges 305 | 306 | def isdisjoint(self, other: Union[Rangelike, Iterable[Rangelike]]) -> bool: 307 | """ 308 | Returns `True` if there is no overlap between this RangeSet and the 309 | given RangeSet. 310 | 311 | :param other: a rangelike, or iterable of rangelikes, to check if overlaps this RangeSet 312 | :return: False the argument (or any element of an iterable argument) overlap this Rangeset, or True otherwise 313 | """ 314 | # convert to RangeSet 315 | other = RangeSet._to_rangeset(other) 316 | # O(n^2) comparison 317 | # TODO improve efficiency by mergesort/short-circuiting 318 | return all(rng1.isdisjoint(rng2) for rng1 in self._ranges for rng2 in other._ranges) 319 | 320 | def popempty(self) -> None: 321 | """ 322 | Removes all empty ranges from this RangeSet. This is mainly used 323 | internally as a helper method, but can also be used deliberately 324 | (in which case it will usually do nothing). 325 | """ 326 | node = self._ranges.first 327 | while node: 328 | if node.value.isempty(): 329 | # you're not supposed to remove from a list while iterating through it 330 | # however, since this is a linked list, this actually doesn't break! 331 | self._ranges.pop_node(node) 332 | node = node.next 333 | 334 | def getrange(self, item: Union[T, Iterable[T], 'RangeSet']) -> Rangelike: 335 | """ 336 | If the given item is in this RangeSet, returns the specific Range it's 337 | in. 338 | 339 | If the given item is a RangeSet or Iterable and is partly contained in 340 | multiple ranges, then returns a RangeSet with only those ranges that 341 | contain some part of it. 342 | 343 | Otherwise, raises an `IndexError`. 344 | 345 | :param item: item to search for in this RangeSet 346 | :return: if item is a single element, then the Range containing it. If item is iterable, 347 | then a RangeSet containing only Ranges containing items. 348 | """ 349 | if item in self: 350 | for rng in self._ranges: 351 | if item in rng: 352 | return rng 353 | # if that doesn't work, try iterating through it 354 | # if this comes up non-empty, then return that 355 | if _is_iterable_non_string(item): 356 | founds = [ 357 | rng for rng in self._ranges 358 | for subitem in item 359 | if subitem in rng 360 | ] 361 | if founds: 362 | return RangeSet(founds) 363 | raise IndexError(f"'{item}' could not be isolated") 364 | raise IndexError(f"'{item}' is not in this RangeSet") 365 | 366 | def ranges(self) -> List[Range]: 367 | """ 368 | Returns a `list` of the Range objects that this RangeSet contains 369 | 370 | :return: the Ranges that make up this RangeSet 371 | """ 372 | return list(iter(self._ranges)) 373 | 374 | def clear(self) -> None: 375 | """ 376 | Removes all ranges from this RangeSet, leaving it empty. 377 | """ 378 | self._ranges = _LinkedList() 379 | 380 | def complement(self) -> 'RangeSet': 381 | """ 382 | Returns a RangeSet containing all items not present in this RangeSet 383 | 384 | :return: the complement of this RangeSet 385 | """ 386 | return RangeSet(Range()) - self 387 | 388 | def isempty(self) -> bool: 389 | """ 390 | Returns True if this RangeSet contains no values, and False otherwise 391 | 392 | :return: whether this RangeSet is empty 393 | """ 394 | return self._ranges.isempty() or all(r.isempty() for r in self._ranges) 395 | 396 | def copy(self) -> 'RangeSet': 397 | """ 398 | Returns a shallow copy of this RangeSet 399 | 400 | :return: a shallow copy of this RangeSet 401 | """ 402 | return RangeSet(self) 403 | 404 | def isinfinite(self) -> bool: 405 | """ 406 | Returns True if this RangeSet has a negative bound of -Inf or a positive bound of +Inf, 407 | and False otherwise 408 | 409 | :return: whether either furthest bound of this RangeSet is infinite 410 | """ 411 | return self._ranges.first.value.start == -Inf or self._ranges.last.value.end == Inf 412 | 413 | def containseverything(self) -> bool: 414 | """ 415 | Returns True if this RangeSet contains all elements 416 | 417 | :return: whether this RangeSet is infinite and its complement is empty 418 | """ 419 | return self.isinfinite() and self.complement().isempty() 420 | 421 | @staticmethod 422 | def _merge_ranges(ranges: Iterable[Range]) -> _LinkedList[Range]: 423 | """ 424 | Compresses all of the ranges in the given iterable, and 425 | returns a _LinkedList containing them. 426 | 427 | :param ranges: iterable containing ranges to merge 428 | :return: a _LinkedList containing ranges, merged together. 429 | """ 430 | # sort our list of ranges, first 431 | ranges = _LinkedList(sorted(ranges)) 432 | # # determine if we need to do anything 433 | # if len(ranges) < 2: 434 | # return 435 | # try to merge each range with the one after it, up until the end of the list 436 | node = ranges.first 437 | while node and node.next: 438 | prev_range = node.value 439 | next_range = node.next.value 440 | new_range = prev_range.union(next_range) 441 | if new_range is not None: # TODO python 3.8 refactoring - this is a great place for := 442 | node.value = new_range 443 | ranges.pop_after(node) 444 | else: 445 | node = node.next 446 | return ranges 447 | 448 | @staticmethod 449 | def _to_rangeset(other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 450 | """ 451 | Helper method. 452 | Converts the given argument to a RangeSet. This mainly exists to increase performance 453 | by not duplicating things that are already RangeSets, and for the sake of graceful 454 | error handling. 455 | 456 | :param other: Same as arguments for RangeSet's constructor 457 | :return: the given RangeSet, if `other` is a RangeSet, or a RangeSet constructed from it otherwise 458 | """ 459 | if not isinstance(other, RangeSet): 460 | try: 461 | other = RangeSet(other) 462 | except ValueError: 463 | raise ValueError(f"Cannot convert {type(other)} to a RangeSet") 464 | return other 465 | 466 | def __contains__(self, item: Union[T, Rangelike]) -> bool: 467 | """ 468 | Returns true if this RangeSet completely contains the given item, Range, 469 | RangeSet, or iterable (which will be assumed to be a RangeSet unless 470 | coercing it into one fails, in which case it will be assumed to be an 471 | item). 472 | Returns false otherwise. 473 | A RangeSet will always contain itself. 474 | 475 | :param item: item to check if is contained in this RangeSet 476 | :return: whether the item is present in this RangeSet 477 | """ 478 | if self == item: 479 | return True 480 | with suppress(TypeError): 481 | if _is_iterable_non_string(item): 482 | with suppress(ValueError): 483 | return all( 484 | any(subitem in rng for rng in self._ranges) 485 | for subitem in RangeSet._to_rangeset(item) 486 | ) 487 | return any(item in rng for rng in self._ranges) 488 | 489 | def __invert__(self) -> 'RangeSet': 490 | """ 491 | Equivalent to self.complement() 492 | 493 | :return: a RangeSet containing everything that is not in this RangeSet 494 | """ 495 | return self.complement() 496 | 497 | def __eq__(self, other: Union[Range, 'RangeSet']) -> bool: 498 | """ 499 | Returns True if this RangeSet's ranges exactly match the other 500 | RangeSet's ranges, or if the given argument is a Range and this 501 | RangeSet contains only one identical Range 502 | 503 | :param other: other object to check equality with 504 | :return: whether this Rangeset equals the given object 505 | """ 506 | if isinstance(other, RangeSet): 507 | return len(self._ranges) == len(other._ranges) and \ 508 | all(mine == theirs for mine, theirs in zip(self._ranges, other._ranges)) 509 | elif isinstance(other, Range): 510 | return len(self._ranges) == 1 and self._ranges[0] == other 511 | else: 512 | return False 513 | 514 | def __lt__(self, other: Union[Range, 'RangeSet']) -> bool: 515 | """ 516 | Returns an ordering-based comparison based on the lowest ranges in 517 | self and other. 518 | 519 | :param other: other object to compare with 520 | :return: True if this RangeSet should be ordered before the other object, False otherwise 521 | """ 522 | if isinstance(other, RangeSet): 523 | # return the first difference between this range and the next range 524 | for my_val, their_val in zip(self._ranges, other._ranges): 525 | if my_val != their_val: 526 | return my_val < their_val 527 | return len(self._ranges) < len(other._ranges) 528 | elif isinstance(other, Range): 529 | # return based on the first range in this RangeSet 530 | return len(self._ranges) >= 1 and self._ranges[0] < other 531 | else: 532 | return False 533 | 534 | def __gt__(self, other: Union[Range, 'RangeSet']) -> bool: 535 | """ 536 | Returns an ordering-based comparison based on the lowest ranges in 537 | self and other. 538 | 539 | :param other: other object to compare with 540 | :return: True if this RangeSet should be ordered after the other object, False otherwise 541 | """ 542 | if isinstance(other, RangeSet): 543 | # return the first difference between this range and the next range 544 | for my_val, their_val in zip(self._ranges, other._ranges): 545 | if my_val != their_val: 546 | return my_val > their_val 547 | return len(self._ranges) > len(other._ranges) 548 | elif isinstance(other, Range): 549 | # return based on the first range in this RangeSet 550 | return len(self._ranges) >= 1 and self._ranges[0] > other 551 | else: 552 | return False 553 | 554 | def __le__(self, other: Union[Range, 'RangeSet']) -> bool: 555 | """ 556 | :param other: other object to compare with 557 | :return: True if this RangeSet equals or should be ordered before the other object, False otherwise 558 | """ 559 | return self < other or self == other 560 | 561 | def __ge__(self, other: Union[Range, 'RangeSet']) -> bool: 562 | """ 563 | :param other: other object to compare with 564 | :return: True if this RangeSet equals or should be ordered after the other object, False otherwise 565 | """ 566 | return self > other or self == other 567 | 568 | def __ne__(self, other: Union[Range, 'RangeSet']) -> bool: 569 | """ 570 | :param other: other object to compare with 571 | :return: True if this RangeSet is not equal to the other object, False if it is equal 572 | """ 573 | return not self == other 574 | 575 | def __and__(self, other: Union[Range, 'RangeSet']) -> bool: 576 | """ returns (self & other), identical to :func:`~RangeSet.intersection` """ 577 | return self.intersection(other) 578 | 579 | def __or__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 580 | """ returns (self | other), identical to :func:`~RangeSet.union` """ 581 | return self.union(other) 582 | 583 | def __xor__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 584 | """ returns (self ^ other), identical to :func:`~RangeSet.symmetric_difference` """ 585 | return self.symmetric_difference(other) 586 | 587 | def __sub__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 588 | """ returns (self - other), identical to :func:`~RangeSet.difference` """ 589 | return self.difference(other) 590 | 591 | def __add__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 592 | """ Returns (self + other), identical to :func:`~RangeSet.union` """ 593 | return self.union(other) 594 | 595 | def __iand__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 596 | """ Executes (self &= other), identical to :func:`~RangeSet.intersection_update` """ 597 | self.intersection_update(other) 598 | return self 599 | 600 | def __ior__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 601 | """ Executes (self |= other), identical to :func:`~RangeSet.update` """ 602 | self.update(other) 603 | return self 604 | 605 | def __ixor__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 606 | """ Executes (self ^= other), identical to :func:`~RangeSet.symmetric_difference_update` """ 607 | self.symmetric_difference_update(other) 608 | return self 609 | 610 | def __iadd__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 611 | """ Executes (self += other), identical to :func:`~RangeSet.update` """ 612 | self.update(other) 613 | return self 614 | 615 | def __isub__(self, other: Union[Rangelike, Iterable[Rangelike]]) -> 'RangeSet': 616 | """ Executes (self -= other), identical to :func:`~RangeSet.difference_update` """ 617 | self.difference_update(other) 618 | return self 619 | 620 | def __iter__(self) -> Iterator[Range]: 621 | """ 622 | Generates the ranges in this object, in order 623 | """ 624 | return iter(self._ranges) 625 | 626 | def __hash__(self): 627 | return hash(tuple(iter(self._ranges))) 628 | 629 | def __bool__(self) -> bool: 630 | """ 631 | :return: False if this RangeSet is empty, True otherwise 632 | """ 633 | return not self.isempty() 634 | 635 | def __str__(self): 636 | return f"{{{', '.join(str(r) for r in self._ranges)}}}" # other possibilities: '∪', ' | ' 637 | 638 | def __repr__(self): 639 | return f"RangeSet{{{', '.join(repr(r) for r in self._ranges)}}}" 640 | -------------------------------------------------------------------------------- /test/test_RangeSet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for RangeSet 3 | """ 4 | import pytest 5 | from ranges import Range, RangeSet 6 | import datetime 7 | from .test_base import asserterror 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "args,ranges,strr,reprr,isempty", [ 12 | ([], [], "{}", "RangeSet{}", True), # empty RangeSet 13 | (["[1..1]"], [Range("[1..1]")], "{[1, 1]}", "RangeSet{Range[1, 1]}", False), # contains one nonempty Range 14 | (["(1..1)"], [Range("(1..1)")], "{(1, 1)}", "RangeSet{Range(1, 1)}", True), # contains one empty Range 15 | (["[1, 2)", "[3, 4)", "[5, 6)"], [Range(1, 2), Range(3, 4), Range(5, 6)], 16 | "{[1, 2), [3, 4), [5, 6)}", "RangeSet{Range[1, 2), Range[3, 4), Range[5, 6)}", False), 17 | ([[]], [], "{}", "RangeSet{}", True), # once-nested empty list 18 | (["[0, 1)", ["[1.5, 2)", "[2.5, 3)"], "[4, 5)"], [Range(0, 1), Range(1.5, 2), Range(2.5, 3), Range(4, 5)], 19 | "{[0, 1), [1.5, 2), [2.5, 3), [4, 5)}", 20 | "RangeSet{Range[0, 1), Range[1.5, 2), Range[2.5, 3), Range[4, 5)}", False), # mix Rangelike, iterable args 21 | (["[0, 3]", "[2, 4)", "[5, 6]"], [Range(0, 4), Range("[5, 6]")], 22 | "{[0, 4), [5, 6]}", "RangeSet{Range[0, 4), Range[5, 6]}", False), # overlapping 23 | (["[0, 4)", "(1, 3)"], [Range(0, 4)], "{[0, 4)}", "RangeSet{Range[0, 4)}", False), # overlapping 2 24 | ([Range(1, 3), Range(2, 4)], [Range(1, 4)], "{[1, 4)}", "RangeSet{Range[1, 4)}", False), 25 | ([Range('apple', 'carrot'), Range('banana', 'durian')], [Range('apple', 'durian')], 26 | "{[apple, durian)}", "RangeSet{Range['apple', 'durian')}", False), 27 | ([RangeSet("(0, 1)", "(1, 2)", "(2, 3)")], [Range("(0, 1)"), Range("(1, 2)"), Range("(2, 3)")], 28 | "{(0, 1), (1, 2), (2, 3)}", "RangeSet{Range(0, 1), Range(1, 2), Range(2, 3)}", False) 29 | ] 30 | ) 31 | def test_rangeset_constructor_valid(args, ranges, strr, reprr, isempty): 32 | """ 33 | Tests that the constructor of rngset works as intended. Also, as a byproduct, 34 | tests the .ranges(), .__str__(), .__repr__(), .clear(), and .isempty() 35 | """ 36 | rangeset = RangeSet(*args) 37 | assert(ranges == rangeset.ranges()) 38 | assert(strr == str(rangeset)) 39 | assert(reprr == repr(rangeset)) 40 | assert(isempty == rangeset.isempty()) 41 | assert(isempty != bool(rangeset)) 42 | assert(hash(rangeset) == hash(rangeset.copy())) 43 | rangeset.clear() 44 | assert("{}" == str(rangeset)) 45 | assert("RangeSet{}" == repr(rangeset)) 46 | assert(rangeset.isempty()) 47 | assert(not bool(rangeset)) 48 | 49 | 50 | @pytest.mark.parametrize( 51 | "args,error_type", [ 52 | ([[[]]], ValueError), # triply-nested but empty (empty shouldn't matter) 53 | (["4, 5"], ValueError), # invalid range object 54 | ([["4, 5"]], ValueError), # iterable containing invalid range object 55 | ([6], ValueError), # non-RangeLike, non-iterable single argument 56 | ([datetime.date(4, 5, 6)], ValueError), # non-RangeLike, non-iterable single argument 57 | ([Range(4, 5), Range("apple", "banana")], TypeError) # type mismatch 58 | ] 59 | ) 60 | def test_rangeset_constructor_invalid(args, error_type): 61 | asserterror(error_type, RangeSet, args) 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "rngset,to_add,before,after,error_type", [ 66 | (RangeSet(), Range(1, 3), "{}", "{[1, 3)}", None), 67 | (RangeSet(), "[1..3)", "{}", "{[1, 3)}", None), 68 | (RangeSet(Range(1, 2)), Range(3, 4), "{[1, 2)}", "{[1, 2), [3, 4)}", None), 69 | (RangeSet(Range(1, 3)), Range(2, 4), "{[1, 3)}", "{[1, 4)}", None), 70 | (RangeSet(Range(1, 4)), Range(2, 3), "{[1, 4)}", "{[1, 4)}", None), 71 | (RangeSet(Range(1, 4)), Range(3, 4, include_end=True), "{[1, 4)}", "{[1, 4]}", None), 72 | (RangeSet(Range(1, 3), Range(4, 6)), Range(2, 5), "{[1, 3), [4, 6)}", "{[1, 6)}", None), 73 | (RangeSet(Range(1, 2), Range(3, 4)), RangeSet(Range(5, 6), Range(7, 8)), 74 | "{[1, 2), [3, 4)}", "{[1, 2), [3, 4), [5, 6), [7, 8)}", None), 75 | (RangeSet(), RangeSet(Range(1, 2), Range(3, 4)), "{}", "{[1, 2), [3, 4)}", None), 76 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(2, 5), Range(7, 9)), 77 | "{[1, 3), [4, 6)}", "{[1, 6), [7, 9)}", None), 78 | (RangeSet(Range(1, 2), Range(4, 5)), Range(3, 3, include_end=True), 79 | "{[1, 2), [4, 5)}", "{[1, 2), [3, 3], [4, 5)}", None), 80 | (RangeSet(), Range(Range(1, 2), Range(3, 4)), "{}", "{[[1, 2), [3, 4))}", None), 81 | # expected error conditions 82 | (RangeSet(), [Range(1, 2)], "{}", "", ValueError), 83 | (RangeSet(), "1, 3", "{}", "", ValueError), 84 | (RangeSet("[1, 2)"), Range("apple", "banana"), "{[1, 2)}", "", TypeError) 85 | ] 86 | ) 87 | def test_rangeset_add(rngset, to_add, before, after, error_type): 88 | assert(before == str(rngset)) 89 | if error_type is not None: 90 | asserterror(error_type, rngset.add, (to_add,)) 91 | assert(before == str(rngset)) 92 | else: 93 | rngset.add(to_add) 94 | assert(after == str(rngset)) 95 | 96 | 97 | @pytest.mark.parametrize( 98 | "rngset,to_extend,before,after,error_type", [ 99 | # correct adds 100 | (RangeSet(), RangeSet(), "{}", "{}", None), 101 | (RangeSet(), RangeSet("[1, 2]"), "{}", "{[1, 2]}", None), 102 | (RangeSet(), RangeSet(Range(1, 3), Range(4, 6)), "{}", "{[1, 3), [4, 6)}", None), 103 | (RangeSet(), [Range(1, 3), Range(4, 6)], "{}", "{[1, 3), [4, 6)}", None), 104 | (RangeSet(), [Range("apple", "banana"), Range("cherry", "durian")], 105 | "{}", "{[apple, banana), [cherry, durian)}", None), 106 | (RangeSet(Range(1, 3), Range(4, 6)), [Range(7, 9)], "{[1, 3), [4, 6)}", "{[1, 3), [4, 6), [7, 9)}", None), 107 | (RangeSet(Range(1, 3), Range(4, 6)), [Range(2, 5), Range(7, 9)], "{[1, 3), [4, 6)}", "{[1, 6), [7, 9)}", None), 108 | # expected errors 109 | (RangeSet(), (1, 3), "{}", "", ValueError), 110 | (RangeSet(), Range(1, 3), "{}", "", TypeError), 111 | (RangeSet(), "[1, 3)", "{}", "", ValueError), 112 | (RangeSet(), ["[3, 1]"], "{}", "", ValueError), # invalid range definition, valid otherwise 113 | (RangeSet("[1, 3)"), RangeSet(Range("apple", "banana"), Range("cherry", "durian")), 114 | "{[1, 3)}", "{[1, 3)}", TypeError), 115 | (RangeSet(), [Range("apple", "banana"), Range(1, 3)], "{}", "{}", TypeError), 116 | ] 117 | ) 118 | def test_rangeset_extend(rngset, to_extend, before, after, error_type): 119 | assert(before == str(rngset)) 120 | if error_type is not None: 121 | asserterror(error_type, rngset.extend, (to_extend,)) 122 | assert(before == str(rngset)) 123 | else: 124 | rngset.extend(to_extend) 125 | assert(after == str(rngset)) 126 | 127 | 128 | @pytest.mark.parametrize( 129 | "rngset,to_discard,before,after,error_type", [ 130 | # remains unchanged 131 | (RangeSet(), Range(), "{}", "{}", None), 132 | (RangeSet(), Range(1, 3), "{}", "{}", None), 133 | (RangeSet(Range(2, 3)), Range(0, 1), "{[2, 3)}", "{[2, 3)}", None), 134 | (RangeSet(Range(2, 3)), RangeSet((Range(0, 1), Range(4, 5))), "{[2, 3)}", "{[2, 3)}", None), 135 | # is modified properly 136 | (RangeSet(Range(1, 5)), "(2, 4]", "{[1, 5)}", "{[1, 2], (4, 5)}", None), 137 | (RangeSet(Range(1, 5)), Range(3, 5), "{[1, 5)}", "{[1, 3)}", None), 138 | (RangeSet(Range(1, 5)), Range(3, 6), "{[1, 5)}", "{[1, 3)}", None), 139 | (RangeSet(Range(1, 5)), Range(0, 3), "{[1, 5)}", "{[3, 5)}", None), 140 | (RangeSet(Range(1, 5)), Range(1, 3), "{[1, 5)}", "{[3, 5)}", None), 141 | (RangeSet(Range(1, 4), Range(5, 8)), Range(3, 6), "{[1, 4), [5, 8)}", "{[1, 3), [6, 8)}", None), 142 | (RangeSet(Range(1, 4), Range(5, 8)), Range(3, 9), "{[1, 4), [5, 8)}", "{[1, 3)}", None), 143 | (RangeSet(Range(1, 4), Range(5, 8)), Range(3, 4.5), "{[1, 4), [5, 8)}", "{[1, 3), [5, 8)}", None), 144 | (RangeSet(Range(1, 4), Range(5, 8)), Range(0, 6), "{[1, 4), [5, 8)}", "{[6, 8)}", None), 145 | (RangeSet(Range(1, 4), Range(5, 8)), Range(0, 9), "{[1, 4), [5, 8)}", "{}", None), 146 | (RangeSet(Range(1, 4), Range(5, 8)), Range(1, 8), "{[1, 4), [5, 8)}", "{}", None), 147 | (RangeSet(Range(1, 4), Range(5, 8)), Range(), "{[1, 4), [5, 8)}", "{}", None), 148 | (RangeSet(Range(1, 4), Range(5, 8)), "(3, 6]", "{[1, 4), [5, 8)}", "{[1, 3], (6, 8)}", None), 149 | (RangeSet(Range()), Range(5, 8), "{[-inf, inf)}", "{[-inf, 5), [8, inf)}", None), 150 | # error conditions 151 | (RangeSet(), "[3, 1)", "{}", "", ValueError), 152 | (RangeSet(Range(2, 3)), Range("apple", "banana"), "{[2, 3)}", "", TypeError), 153 | (RangeSet(), [Range(1, 2), Range(3, 4)], "{}", "", ValueError), 154 | (RangeSet(), 2, "{}", "", ValueError), 155 | # we'll mostly leave RangeSets and other iterables alone for now, since they get tested later 156 | ] 157 | ) 158 | def test_rangeset_discard(rngset, to_discard, before, after, error_type): 159 | assert(before == str(rngset)) 160 | if error_type is not None: 161 | asserterror(error_type, rngset.discard, (to_discard,)) 162 | assert(before == str(rngset)) 163 | else: 164 | rngset.discard(to_discard) 165 | assert(after == str(rngset)) 166 | 167 | 168 | @pytest.mark.parametrize( 169 | "rngset,to_discard,before,after,error_type", [ 170 | # unchanged 171 | (RangeSet(), Range(), "{}", "{}", None), 172 | (RangeSet(), RangeSet(), "{}", "{}", None), 173 | (RangeSet(), [], "{}", "{}", None), 174 | (RangeSet(), ["(3, 4)"], "{}", "{}", None), 175 | (RangeSet(), RangeSet("(3, 4)"), "{}", "{}", None), 176 | (RangeSet(), ["(3, 4)", "(1, 2)"], "{}", "{}", None), 177 | (RangeSet(), RangeSet("(3, 4)", "(1, 2)"), "{}", "{}", None), 178 | (RangeSet(Range(1, 2), Range(3, 4)), RangeSet("[0, 1)", "[5, 6]"), 179 | "{[1, 2), [3, 4)}", "{[1, 2), [3, 4)}", None), 180 | # changed properly 181 | (RangeSet(Range()), [Range(1, 3), Range(4, 6)], "{[-inf, inf)}", "{[-inf, 1), [3, 4), [6, inf)}", None), 182 | (RangeSet(Range(1, 3)), [Range(2, 4)], "{[1, 3)}", "{[1, 2)}", None), 183 | (RangeSet(Range(1, 4)), [Range(1, 2), Range(3, 4)], "{[1, 4)}", "{[2, 3)}", None), 184 | (RangeSet(Range(3, 6), Range(7, 10)), RangeSet(Range(1, 4), Range(5, 8), Range(9, 11)), 185 | "{[3, 6), [7, 10)}", "{[4, 5), [8, 9)}", None), 186 | (RangeSet(Range(1, 10)), [Range(1, 2), Range(3, 4), Range(5, 6), Range(7, 8), Range(9, 10)], 187 | "{[1, 10)}", "{[2, 3), [4, 5), [6, 7), [8, 9)}", None), 188 | # error conditions 189 | (RangeSet(), [""], "{}", "", ValueError), 190 | (RangeSet(Range(2, 3)), [Range("apple", "banana")], "{[2, 3)}", "", TypeError), 191 | (RangeSet(Range(1, 4)), [Range(2, 3), Range("apple", "banana")], "{[1, 4)}", "", TypeError), 192 | # tests left over from discard() that still apply 193 | (RangeSet(), Range(), "{}", "{}", None), 194 | (RangeSet(), Range(1, 3), "{}", "{}", None), 195 | (RangeSet(Range(2, 3)), Range(0, 1), "{[2, 3)}", "{[2, 3)}", None), 196 | (RangeSet(Range(2, 3)), RangeSet((Range(0, 1), Range(4, 5))), "{[2, 3)}", "{[2, 3)}", None), 197 | (RangeSet(Range(1, 5)), "(2, 4]", "{[1, 5)}", "{[1, 2], (4, 5)}", None), 198 | (RangeSet(Range(1, 5)), Range(3, 5), "{[1, 5)}", "{[1, 3)}", None), 199 | (RangeSet(Range(1, 5)), Range(3, 6), "{[1, 5)}", "{[1, 3)}", None), 200 | (RangeSet(Range(1, 5)), Range(0, 3), "{[1, 5)}", "{[3, 5)}", None), 201 | (RangeSet(Range(1, 5)), Range(1, 3), "{[1, 5)}", "{[3, 5)}", None), 202 | (RangeSet(Range(1, 4), Range(5, 8)), Range(3, 6), "{[1, 4), [5, 8)}", "{[1, 3), [6, 8)}", None), 203 | (RangeSet(Range(1, 4), Range(5, 8)), Range(3, 9), "{[1, 4), [5, 8)}", "{[1, 3)}", None), 204 | (RangeSet(Range(1, 4), Range(5, 8)), Range(3, 4.5), "{[1, 4), [5, 8)}", "{[1, 3), [5, 8)}", None), 205 | (RangeSet(Range(1, 4), Range(5, 8)), Range(0, 6), "{[1, 4), [5, 8)}", "{[6, 8)}", None), 206 | (RangeSet(Range(1, 4), Range(5, 8)), Range(0, 9), "{[1, 4), [5, 8)}", "{}", None), 207 | (RangeSet(Range(1, 4), Range(5, 8)), Range(1, 8), "{[1, 4), [5, 8)}", "{}", None), 208 | (RangeSet(Range(1, 4), Range(5, 8)), Range(), "{[1, 4), [5, 8)}", "{}", None), 209 | (RangeSet(Range(1, 4), Range(5, 8)), "(3, 6]", "{[1, 4), [5, 8)}", "{[1, 3], (6, 8)}", None), 210 | (RangeSet(Range()), Range(5, 8), "{[-inf, inf)}", "{[-inf, 5), [8, inf)}", None), 211 | ] 212 | ) 213 | def test_rangeset_difference(rngset, to_discard, before, after, error_type): 214 | # Tests both .difference() and .difference_update() 215 | assert(before == str(rngset)) 216 | if error_type is not None: 217 | asserterror(error_type, rngset.difference, (to_discard,)) 218 | asserterror(error_type, rngset.difference_update, (to_discard,)) 219 | assert(before == str(rngset)) 220 | else: 221 | copy_rngset = rngset.copy() 222 | new_rngset = rngset.difference(to_discard) 223 | rngset.difference_update(to_discard) 224 | sub_rngset = rngset - to_discard 225 | copy_rngset -= to_discard 226 | assert(after == str(new_rngset)) 227 | assert(after == str(rngset)) 228 | assert(after == str(sub_rngset)) 229 | assert(after == str(copy_rngset)) 230 | 231 | 232 | @pytest.mark.parametrize( 233 | "rngset,to_intersect,before,after,error_type", [ 234 | (RangeSet(Range(2, 3), Range(7, 8)), [Range(1, 4), Range(5, 6)], "{[2, 3), [7, 8)}", "{[2, 3)}", None), 235 | (RangeSet(Range(1, 4)), Range(3, 5), "{[1, 4)}", "{[3, 4)}", None), 236 | (RangeSet(Range(1, 4)), RangeSet(Range(3, 5)), "{[1, 4)}", "{[3, 4)}", None), 237 | (RangeSet(Range(3, 5)), RangeSet(Range(1, 4)), "{[3, 5)}", "{[3, 4)}", None), 238 | (RangeSet(Range(1, 5)), RangeSet(Range(2, 3)), "{[1, 5)}", "{[2, 3)}", None), 239 | (RangeSet(Range(2, 3)), RangeSet(Range(1, 5)), "{[2, 3)}", "{[2, 3)}", None), 240 | (RangeSet(Range(1, 6)), RangeSet(Range(2, 3), Range(4, 5)), "{[1, 6)}", "{[2, 3), [4, 5)}", None), 241 | (RangeSet(Range(2, 3), Range(4, 5)), RangeSet(Range(1, 6)), "{[2, 3), [4, 5)}", "{[2, 3), [4, 5)}", None), 242 | (RangeSet(Range(1, 3), Range(4, 6)), Range(2, 5), "{[1, 3), [4, 6)}", "{[2, 3), [4, 5)}", None), 243 | (RangeSet(Range(2, 5)), RangeSet(Range(1, 3), Range(4, 6)), "{[2, 5)}", "{[2, 3), [4, 5)}", None), 244 | (RangeSet(Range(1, 4), Range(5, 8)), RangeSet(Range(2, 3), Range(6, 7)), 245 | "{[1, 4), [5, 8)}", "{[2, 3), [6, 7)}", None), 246 | (RangeSet(Range(2, 3), Range(6, 7)), RangeSet(Range(1, 4), Range(5, 8)), 247 | "{[2, 3), [6, 7)}", "{[2, 3), [6, 7)}", None), 248 | (RangeSet(Range(1, 4), Range(5, 6)), RangeSet(Range(2, 3), Range(7, 8)), "{[1, 4), [5, 6)}", "{[2, 3)}", None), 249 | (RangeSet(Range(2, 3), Range(7, 8)), RangeSet(Range(1, 4), Range(5, 6)), "{[2, 3), [7, 8)}", "{[2, 3)}", None), 250 | (RangeSet(Range(1, 4), Range(5, 6)), Range(2, 10), "{[1, 4), [5, 6)}", "{[2, 4), [5, 6)}", None), 251 | (RangeSet(Range(2, 10)), RangeSet(Range(1, 4), Range(5, 6)), "{[2, 10)}", "{[2, 4), [5, 6)}", None), 252 | (RangeSet(Range(1, 4)), Range(1, 4), "{[1, 4)}", "{[1, 4)}", None), 253 | (RangeSet(Range(1, 4)), RangeSet(Range(1, 4)), "{[1, 4)}", "{[1, 4)}", None), 254 | (RangeSet(Range(1, 4), Range(5, 8)), RangeSet(Range(1, 3), Range(6, 8, include_end=True)), 255 | "{[1, 4), [5, 8)}", "{[1, 3), [6, 8)}", None), 256 | (RangeSet(Range(1, 4), Range(5, 8)), RangeSet(Range(1, 3), Range(6, 8)), 257 | "{[1, 4), [5, 8)}", "{[1, 3), [6, 8)}", None), 258 | (RangeSet(Range("apple", "cherry", include_end=True)), RangeSet(Range("banana", "durian", include_start=False)), 259 | "{[apple, cherry]}", "{(banana, cherry]}", None), 260 | (RangeSet(Range("banana", "durian", include_start=False)), RangeSet(Range("apple", "cherry", include_end=True)), 261 | "{(banana, durian)}", "{(banana, cherry]}", None), 262 | (RangeSet(), RangeSet(), "{}", "{}", None), 263 | (RangeSet(Range(1, 4)), RangeSet(), "{[1, 4)}", "{}", None), 264 | (RangeSet(Range()), RangeSet(), "{[-inf, inf)}", "{}", None), 265 | (RangeSet(), Range(), "{}", "{}", None), 266 | (RangeSet(Range()), RangeSet(Range(1, 4)), "{[-inf, inf)}", "{[1, 4)}", None), 267 | (RangeSet(Range(1, 4)), RangeSet(Range()), "{[1, 4)}", "{[1, 4)}", None), 268 | (RangeSet(Range(include_end=True)), Range(include_start=False), "{[-inf, inf]}", "{(-inf, inf)}", None), 269 | (RangeSet(Range(include_start=False)), Range(include_end=True), "{(-inf, inf)}", "{(-inf, inf)}", None), 270 | # error conditions 271 | (RangeSet(), [""], "{}", "", ValueError), 272 | (RangeSet(Range(2, 3)), [Range("apple", "banana")], "{[2, 3)}", "", TypeError), 273 | (RangeSet(Range(1, 4)), [Range(2, 3), Range("apple", "banana")], "{[1, 4)}", "", TypeError), 274 | ] 275 | ) 276 | def test_rangeset_intersection(rngset, to_intersect, before, after, error_type): 277 | # tests both .intersection() and .intersection_update() 278 | assert(before == str(rngset)) 279 | if error_type is not None: 280 | asserterror(error_type, rngset.intersection, (to_intersect,)) 281 | asserterror(error_type, rngset.intersection_update, (to_intersect,)) 282 | assert(before == str(rngset)) 283 | else: 284 | copy_rngset = rngset.copy() 285 | new_rngset = rngset.intersection(to_intersect) 286 | and_rngset = rngset & to_intersect 287 | rngset.intersection_update(to_intersect) 288 | copy_rngset &= to_intersect 289 | assert(after == str(new_rngset)) 290 | assert(after == str(rngset)) 291 | assert(after == str(and_rngset)) 292 | assert(after == str(copy_rngset)) 293 | 294 | 295 | @pytest.mark.parametrize( 296 | "rngset,to_union,before,after,error_type", [ 297 | (RangeSet(), RangeSet(), "{}", "{}", None), 298 | (RangeSet(), Range(1, 4), "{}", "{[1, 4)}", None), 299 | (RangeSet(Range(1, 4)), RangeSet(), "{[1, 4)}", "{[1, 4)}", None), 300 | (RangeSet(), Range(), "{}", "{[-inf, inf)}", None), 301 | (RangeSet(Range(1, 4)), Range(), "{[1, 4)}", "{[-inf, inf)}", None), 302 | (RangeSet(Range()), Range(1, 4), "{[-inf, inf)}", "{[-inf, inf)}", None), 303 | (RangeSet(Range()), RangeSet(), "{[-inf, inf)}", "{[-inf, inf)}", None), 304 | (RangeSet(Range(1, 4)), [Range(3, 6)], "{[1, 4)}", "{[1, 6)}", None), 305 | (RangeSet(Range(1, 4)), Range(3, 6), "{[1, 4)}", "{[1, 6)}", None), 306 | (RangeSet(Range(3, 6)), Range(1, 4), "{[3, 6)}", "{[1, 6)}", None), 307 | (RangeSet(Range(1, 4)), Range(1, 4), "{[1, 4)}", "{[1, 4)}", None), 308 | (RangeSet(Range(1, 4)), Range(1, 4, include_end=True), "{[1, 4)}", "{[1, 4]}", None), 309 | (RangeSet(Range(1, 4)), [Range(1, 2), Range(3, 4, include_end=True)], "{[1, 4)}", "{[1, 4]}", None), 310 | (RangeSet(Range(1, 2)), Range(3, 4), "{[1, 2)}", "{[1, 2), [3, 4)}", None), 311 | (RangeSet(Range(3, 4)), Range(1, 2), "{[3, 4)}", "{[1, 2), [3, 4)}", None), 312 | (RangeSet(Range(1, 3), Range(4, 7), Range(8, 10)), [Range(2, 5), Range(6, 9)], 313 | "{[1, 3), [4, 7), [8, 10)}", "{[1, 10)}", None), 314 | (RangeSet(Range(1, 5)), Range(2, 3), "{[1, 5)}", "{[1, 5)}", None), 315 | (RangeSet(Range(1, 4), Range(5, 7)), RangeSet(Range(4, 5), Range(8, 10)), 316 | "{[1, 4), [5, 7)}", "{[1, 7), [8, 10)}", None), 317 | (RangeSet(Range(1, 3)), Range(1, 2), "{[1, 3)}", "{[1, 3)}", None), 318 | (RangeSet(Range(1, 3)), Range(2, 3), "{[1, 3)}", "{[1, 3)}", None), 319 | # error conditions 320 | (RangeSet(), [""], "{}", "", ValueError), 321 | (RangeSet(Range(2, 3)), [Range("apple", "banana")], "{[2, 3)}", "", TypeError), 322 | (RangeSet(Range(2, 3)), [Range(4, 5), Range("apple", "banana")], "{[2, 3)}", "", TypeError), 323 | ] 324 | ) 325 | def test_rangeset_union(rngset, to_union, before, after, error_type): 326 | # tests .union() and .update() 327 | assert(before == str(rngset)) 328 | if error_type is not None: 329 | asserterror(error_type, rngset.union, (to_union,)) 330 | asserterror(error_type, rngset.update, (to_union,)) 331 | assert(before == str(rngset)) 332 | else: 333 | copy_rngset = rngset.copy() 334 | copy_rngset2 = rngset.copy() 335 | new_rngset = rngset.union(to_union) 336 | add_rngset = rngset + to_union 337 | or_rngset = rngset | to_union 338 | rngset.update(to_union) 339 | copy_rngset += to_union 340 | copy_rngset2 |= to_union 341 | assert(after == str(new_rngset)) 342 | assert(after == str(add_rngset)) 343 | assert(after == str(or_rngset)) 344 | assert(after == str(rngset)) 345 | assert(after == str(copy_rngset)) 346 | assert(after == str(copy_rngset2)) 347 | 348 | 349 | @pytest.mark.parametrize( 350 | "rngset,to_symdiff,before,after,error_type", [ 351 | (RangeSet(), RangeSet(), "{}", "{}", None), 352 | (RangeSet(), RangeSet(Range(2, 4)), "{}", "{[2, 4)}", None), 353 | (RangeSet(), Range(2, 4), "{}", "{[2, 4)}", None), 354 | (RangeSet(), [Range(2, 4)], "{}", "{[2, 4)}", None), 355 | (RangeSet(Range(2, 4)), RangeSet(), "{[2, 4)}", "{[2, 4)}", None), 356 | (RangeSet(), [Range(2, 4), Range(5, 7)], "{}", "{[2, 4), [5, 7)}", None), 357 | (RangeSet(Range(2, 4), Range(5, 7)), RangeSet(), "{[2, 4), [5, 7)}", "{[2, 4), [5, 7)}", None), 358 | (RangeSet(), Range(), "{}", "{[-inf, inf)}", None), 359 | (RangeSet(Range()), RangeSet(), "{[-inf, inf)}", "{[-inf, inf)}", None), 360 | (RangeSet(Range()), Range(2, 4), "{[-inf, inf)}", "{[-inf, 2), [4, inf)}", None), 361 | (RangeSet(Range(2, 4)), Range(), "{[2, 4)}", "{[-inf, 2), [4, inf)}", None), 362 | (RangeSet(Range()), [Range(2, 3), Range(4, 5)], "{[-inf, inf)}", "{[-inf, 2), [3, 4), [5, inf)}", None), 363 | (RangeSet(Range()), Range(), "{[-inf, inf)}", "{}", None), 364 | (RangeSet(Range(1, 2)), Range(1, 2), "{[1, 2)}", "{}", None), 365 | (RangeSet(Range(1, 4)), RangeSet(Range(2, 3)), "{[1, 4)}", "{[1, 2), [3, 4)}", None), 366 | (RangeSet(Range(1, 3)), Range(2, 4), "{[1, 3)}", "{[1, 2), [3, 4)}", None), 367 | (RangeSet(Range(1, 3), Range(4, 6)), Range(2, 5), "{[1, 3), [4, 6)}", "{[1, 2), [3, 4), [5, 6)}", None), 368 | (RangeSet(Range(2, 5)), [Range(1, 3), Range(4, 6)], "{[2, 5)}", "{[1, 2), [3, 4), [5, 6)}", None), 369 | # error conditions 370 | (RangeSet(), [""], "{}", "", ValueError), 371 | (RangeSet(Range(2, 3)), [Range("apple", "banana")], "{[2, 3)}", "", TypeError), 372 | (RangeSet(Range(2, 3)), [Range(4, 5), Range("apple", "banana")], "{[2, 3)}", "", TypeError), 373 | ] 374 | ) 375 | def test_rangeset_symdiff(rngset, to_symdiff, before, after, error_type): 376 | # tests .symmetric_difference() and .symmetric_difference_update() 377 | assert(before == str(rngset)) 378 | if error_type is not None: 379 | asserterror(error_type, rngset.symmetric_difference, (to_symdiff,)) 380 | asserterror(error_type, rngset.symmetric_difference_update, (to_symdiff,)) 381 | assert(before == str(rngset)) 382 | else: 383 | copy_rngset = rngset.copy() 384 | new_rngset = rngset.symmetric_difference(to_symdiff) 385 | xor_rngset = rngset ^ to_symdiff 386 | rngset.symmetric_difference_update(to_symdiff) 387 | copy_rngset ^= to_symdiff 388 | assert(after == str(new_rngset)) 389 | assert(after == str(xor_rngset)) 390 | assert(after == str(rngset)) 391 | assert(after == str(copy_rngset)) 392 | 393 | 394 | @pytest.mark.parametrize( 395 | "rngset,expected", [ 396 | # testcases from Range 397 | (RangeSet(Range()), RangeSet()), # complement of an infinite range is a range with no elements 398 | (RangeSet(Range('(5..5)')), RangeSet(Range())), # complement of an empty range is an infinite range 399 | (RangeSet(Range('(5..5]')), RangeSet(Range())), 400 | (RangeSet(Range('[5..5)')), RangeSet(Range())), 401 | (RangeSet(Range('[5..5]')), RangeSet(Range(end=5), Range(start=5, include_start=False))), # single point 402 | (RangeSet(Range(1, 3)), RangeSet(Range(end=1), Range(start=3))), # complement of normal range 403 | (RangeSet(Range('[2..3]')), RangeSet(Range('[-inf, 2)'), Range('(3, inf)'))), # normal range, bounds-check 404 | (RangeSet(Range('(2..3)')), RangeSet(Range('[-inf, 2]'), Range('[3, inf)'))), 405 | (RangeSet(Range(end=-1)), RangeSet(Range(start=-1))), # complement of one-side-infinite range 406 | (RangeSet(Range(end=-1, include_end=True)), RangeSet(Range(start=-1, include_start=False))), 407 | (RangeSet(Range(start=1, include_start=False)), RangeSet(Range(end=1, include_end=True))), 408 | (RangeSet(Range(start=1)), RangeSet(Range(end=1))), 409 | (RangeSet(Range('inquisition', 'spanish')), RangeSet(Range(end='inquisition'), Range(start='spanish'))), 410 | (RangeSet(Range('e', 'e', include_start=False)), RangeSet(Range())), 411 | # new testcases for RangeSet 412 | (RangeSet(), RangeSet(Range())), 413 | (RangeSet('[1, 2)', '[3, 4)', '(5, 6)'), RangeSet('[-inf, 1)', '[2, 3)', '[4, 5]', '[6, inf)')), 414 | ] 415 | ) 416 | def test_rangeset_complement(rngset, expected): 417 | assert(expected == rngset.complement()) 418 | assert(expected == ~rngset) 419 | rngset.popempty() 420 | assert(rngset == expected.complement()) 421 | assert(rngset == ~expected) 422 | 423 | 424 | @pytest.mark.parametrize( 425 | "rng1,rng2,isdisjoint,error_type", [ 426 | # test cases carried over from Range.isdisjoint() 427 | (RangeSet(Range(1, 3)), Range(2, 4), False, None), 428 | (RangeSet(Range(1, 3)), Range(4, 6), True, None), 429 | (RangeSet(Range(1, 3)), Range(3, 5), True, None), 430 | (RangeSet(Range(1, 3)), "[3, 5)", True, None), 431 | (RangeSet(Range(1, 4)), Range(2, 3), False, None), 432 | (RangeSet(Range(1, 3, include_end=True)), Range(3, 5), False, None), 433 | (RangeSet(Range()), Range(1, 3), False, None), 434 | # new original test cases 435 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(3, 4)), True, None), 436 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(3, 4, include_end=True)), False, None), 437 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(3, 4), Range(6, 7)), True, None), 438 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(3, 4), Range(6, 7), Range(1, 2)), False, None), 439 | (RangeSet(Range(1, 4)), RangeSet(), True, None), 440 | (RangeSet(Range(2, 4)), RangeSet(Range(0, 1), Range(5, 6)), True, None), 441 | (RangeSet(Range(2, 4)), RangeSet(Range(1, 3)), False, None), 442 | (RangeSet(Range(2, 4)), RangeSet(Range(1, 3), Range(5, 6)), False, None), 443 | # errors 444 | (RangeSet(Range(1, 3)), RangeSet(Range("apple", "banana")), None, TypeError), 445 | (RangeSet(Range(1, 3)), [Range(4, 6), Range("apple", "banana")], None, TypeError), 446 | (RangeSet(Range(1, 3)), Range("apple", "banana"), None, TypeError), 447 | (RangeSet(Range(1, 3)), "2, 4", None, ValueError), 448 | ] 449 | ) 450 | def test_rangeset_isdisjoint(rng1, rng2, isdisjoint, error_type): 451 | if error_type is not None: 452 | asserterror(error_type, rng1.isdisjoint, (rng2,)) 453 | else: 454 | assert(rng1.isdisjoint(rng2) == RangeSet(rng2).isdisjoint(rng1)) 455 | assert(isdisjoint == rng1.isdisjoint(rng2)) 456 | 457 | 458 | @pytest.mark.parametrize( 459 | "rngset,before,after", [ 460 | (RangeSet(), "{}", "{}"), 461 | (RangeSet(Range(1, 3)), "{[1, 3)}", "{[1, 3)}"), 462 | (RangeSet("[1, 1]"), "{[1, 1]}", "{[1, 1]}"), 463 | (RangeSet("[1, 1)"), "{[1, 1)}", "{}"), 464 | (RangeSet("(1, 1]"), "{(1, 1]}", "{}"), 465 | (RangeSet("(1, 1)"), "{(1, 1)}", "{}"), 466 | (RangeSet(Range(2, 3), Range(1, 1, include_start=False)), "{(1, 1), [2, 3)}", "{[2, 3)}"), 467 | (RangeSet(Range(1, 1, include_start=False)), "{(1, 1)}", "{}"), # contains one empty range, should be removed 468 | (RangeSet(Range(1, 1, include_end=True)), "{[1, 1]}", "{[1, 1]}"), # one non-empty range, should be preserved 469 | (RangeSet(Range(1, 2, include_start=False), Range(3, 3, include_start=False)), "{(1, 2), (3, 3)}", "{(1, 2)}"), 470 | ] 471 | ) 472 | def test_rangeset_popempty(rngset, before, after): 473 | assert(before == str(rngset)) 474 | rngset.popempty() 475 | assert(after == str(rngset)) 476 | 477 | 478 | @pytest.mark.parametrize( 479 | "rngset,item,rng", [ 480 | (RangeSet(Range(1, 2)), 1, Range(1, 2)), 481 | (RangeSet(Range(1, 2)), 1.5, Range(1, 2)), 482 | (RangeSet(Range(1, 2)), 2, IndexError), 483 | (RangeSet(Range(1, 2)), 0, IndexError), 484 | (RangeSet(Range(1, 2)), 'banana', TypeError), 485 | (RangeSet(Range(1, 4)), Range(2, 3), Range(1, 4)), 486 | (RangeSet(Range(1, 3), Range(4, 6)), 2, Range(1, 3)), 487 | (RangeSet(Range(1, 3), Range(4, 6)), 5, Range(4, 6)), 488 | (RangeSet(Range(1, 3), Range(4, 6)), Range(2, 5), IndexError), 489 | (RangeSet(Range(1, 6)), RangeSet(Range(2, 3), Range(4, 5)), Range(1, 6)), 490 | (RangeSet(Range(1, 6), Range(7, 8)), RangeSet(Range(2, 3), Range(4, 5)), Range(1, 6)), 491 | (RangeSet(Range(1, 4), Range(5, 8)), RangeSet(Range(2, 3), Range(6, 7)), RangeSet(Range(1, 4), Range(5, 8))), 492 | (RangeSet(Range(1, 4), Range(5, 8), Range(10, 11)), RangeSet(Range(2, 3), Range(6, 7)), 493 | RangeSet(Range(1, 4), Range(5, 8))), 494 | (RangeSet(Range(1, 4), Range(5, 8)), RangeSet(Range(2, 3), Range(6, 9)), IndexError), 495 | (RangeSet(Range(1, 3)), Range('apple', 'banana'), TypeError), 496 | (RangeSet(Range('a', 'ba'), Range('bb', 'da'), Range('db', 'z')), 'bubble', Range('bb', 'da')), 497 | (RangeSet(Range((1, 2), (3, 3))), (1, 999), Range((1, 2), (3, 3))), 498 | (RangeSet(Range((1, 2), (3, 3))), (3, 2), Range((1, 2), (3, 3))), 499 | (RangeSet(Range((1, 2), (3, 3))), (3, 4), IndexError), 500 | ] 501 | ) 502 | def test_rangeset_getrange(rngset, item, rng): 503 | # also tests __contains__ 504 | assert(rngset in rngset) 505 | if rng in (IndexError,): 506 | # test the error condition 507 | assert (item not in rngset) 508 | asserterror(rng, rngset.getrange, (item,)) 509 | elif rng in (ValueError, TypeError): 510 | asserterror(rng, rngset.__contains__, (item,)) 511 | asserterror(rng, rngset.getrange, (item,)) 512 | else: 513 | # test the actual condition 514 | assert(item in rngset) 515 | assert(item in rng) 516 | assert(rng == rngset.getrange(item)) 517 | 518 | 519 | @pytest.mark.parametrize( 520 | "lesser,greater,equal", [ 521 | # from Range comparisons 522 | (RangeSet(Range()), Range(), True), 523 | (RangeSet(Range(1, 2)), Range("[1, 2)"), True), 524 | (RangeSet(Range(1, 2)), Range(1, 2, include_end=True), False), 525 | (RangeSet(Range(1, 2, include_start=True)), Range(1, 2, include_start=False), False), 526 | (RangeSet(Range()), Range(1, 2), False), 527 | (RangeSet(Range(1, 4)), Range(2, 3), False), 528 | (RangeSet(Range(1, 3)), Range(2, 4), False), 529 | # new RangeSet-specific stuff 530 | (RangeSet(Range(1, 3), Range(4, 6)), Range(2, 5), False), 531 | (RangeSet(Range(1, 3)), RangeSet(Range(1, 3), Range(4, 6)), False), 532 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(1, 3), Range(4, 6)), True), 533 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(1, 3), Range(4, 6, include_end=True)), False), 534 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(1, 3, include_end=True), Range(4, 6)), False), 535 | (RangeSet(Range(1, 3), Range(4, 6)), RangeSet(Range(1, 3, include_end=True), Range(4, 5)), False), 536 | ] 537 | ) 538 | def test_rangeset_comparisons(lesser, greater, equal): 539 | assert (equal == (lesser == greater)) 540 | assert (equal != (lesser != greater)) 541 | assert (lesser <= greater) 542 | assert (equal != (lesser < greater)) 543 | assert (not (greater < lesser)) 544 | assert (equal != (greater > lesser)) 545 | assert (not (lesser > greater)) 546 | assert (greater >= lesser) 547 | 548 | 549 | def test_rangeset_docstring(): 550 | a = RangeSet() 551 | b = RangeSet([Range(0, 1), Range(2, 3), Range(4, 5)]) 552 | c = RangeSet(Range(0, 1), Range(2, 3), Range(4, 5)) 553 | d = RangeSet("[0, 1)", ["[1.5, 2)", "[2.5, 3)"], "[4, 5]") 554 | assert(str(a) == "{}") 555 | assert(str(b) == "{[0, 1), [2, 3), [4, 5)}") 556 | assert(str(c) == "{[0, 1), [2, 3), [4, 5)}") 557 | assert(b == c) 558 | assert(str(d) == "{[0, 1), [1.5, 2), [2.5, 3), [4, 5]}") 559 | 560 | asserterror(ValueError, RangeSet, ([[Range(0, 1), Range(2, 3)], [Range(4, 5), Range(6, 7)]],)) 561 | 562 | f = RangeSet("[0, 3]", "[2, 4)", "[5, 6]") 563 | assert(str(f) == "{[0, 4), [5, 6]}") 564 | -------------------------------------------------------------------------------- /ranges/Range.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | import re 3 | from ._helper import _InfiniteValue, Inf, Rangelike, RangelikeString 4 | import ranges # avoid circular imports by explicitly referring to ranges.RangeSet when needed 5 | from typing import Any, TypeVar, Union 6 | 7 | 8 | T = TypeVar('T', bound=Any) 9 | _sentinel = object() 10 | 11 | 12 | class Range: 13 | """ Range(self, *args) 14 | A class representing a range from a start value to an end value. 15 | The start and end need not be numeric, but they must be comparable. 16 | A Range is immutable, but not strictly so - nevertheless, it should 17 | not be modified directly. 18 | 19 | A new Range can be constructed in several ways: 20 | 21 | 1. From an existing `Range` object 22 | 23 | >>> a = Range() # range from -infinity to +infinity (see point #4 for how "infinity" works here) 24 | >>> b = Range(a) # copy of a 25 | 26 | 2. From a string, in the format "[start, end)". 27 | 28 | Both '()' (exclusive) and '[]' (inclusive) are valid brackets, 29 | and `start` and `end` must be able to be parsed as floats. 30 | Brackets must be present (a ValueError will be raised if they aren't). 31 | 32 | Also, the condition `start <= end` must be True. 33 | 34 | If constructing a Range from a string, then the keyword arguments 35 | `include_start` and `include_end` will be ignored. 36 | 37 | >>> c = Range("(-3, 5.5)") # Infers include_start and include_end to both be false 38 | >>> d = Range("[-3, 5.5)") # Infers include_start to be true, include_end to be false 39 | 40 | 3. From two positional arguments representing `start` and `end`. 41 | `start` and `end` may be anything so long as the condition 42 | `start <= end` is True and does not error. Both `start` and 43 | `end` must be given together as positional arguments; if only 44 | one is given, then the program will try to consider it as an 45 | iterable. 46 | 47 | >>> e = Range(3, 5) 48 | >>> f = Range(3, 5, include_start=False, include_end=True) 49 | >>> print(e) # [3, 5) 50 | >>> print(f) # (3, 5] 51 | 52 | 4. From the keyword arguments `start` and/or `end`. 53 | `start` and `end` 54 | may be anything so long as the condition `start <= end` is True and 55 | does not error. If not provided, `start` is set to -infinity by 56 | default, and `end` is set to +infinity by default. If any of the 57 | other methods are used to provide start and end values for the Range, 58 | then these keywords will be ignored. 59 | 60 | >>> g = Range(start=3, end=5) # [3, 5) 61 | >>> h = Range(start=3) # [3, inf) 62 | >>> i = Range(end=5) # [-inf, 5) 63 | >>> j = Range(start=3, end=5, include_start=False, include_end=True) # (3, 5] 64 | 65 | You can also use the default infinite bounds with other types: 66 | 67 | >>> k = Range(start=datetime.date(1969, 10, 5)) # [1969-10-05, inf) includes any date after 5/10/1969 68 | >>> l = Range(end="ni", include_end=True) # [-inf, ni) all strings lexicographically less than 'ni' 69 | 70 | When constructing a Range in any way other than via a string, 71 | non-numeric values may be used as arguments. This includes dates: 72 | 73 | >>> import datetime 74 | >>> m = Range(datetime.date(1478, 11, 1), datetime.date(1834, 7, 15)) 75 | >>> print(datetime.date(1492, 8, 3) in m) # True 76 | >>> print(datetime.date(1979, 8, 17) in m) # False 77 | 78 | and strings (using lexicographic comparisons): 79 | 80 | >>> n = Range("killer", "rabbit") 81 | >>> print("grenade" in n) # False 82 | >>> print("pin" in n) # True 83 | >>> print("three" in n) # False 84 | 85 | or any other comparable type. 86 | 87 | By default, the start of a range (`include_start`) will be inclusive, 88 | and the end of a range (`include_end`) will be exclusive. User-given 89 | values for `include_start` and `include_end` will override these 90 | defaults. 91 | 92 | The Range data structure uses a special notion of "infinity" that works 93 | with all types, not just numeric ones. This allows for endless ranges 94 | in datatypes that do not provide their own notions of infinity, such 95 | as datetimes. Be warned that a range constructed without arguments will 96 | then contain every value that can possibly be contained in a Range: 97 | 98 | >>> q = Range(include_end=True) 99 | >>> print(q) # [-inf, inf] 100 | >>> print(0 in q) # True 101 | >>> print(1 in q) # True 102 | >>> print(-99e99 in q) # True 103 | >>> print("one" in q) # True 104 | >>> print(datetime.date(1975, 3, 14) in q) # True 105 | >>> print(None in q) # True 106 | 107 | Although, for numeric types, infinity automatically conforms to the 108 | mathematical infinity of IEEE 754: 109 | 110 | >>> print(float('nan') in q) # False 111 | 112 | Mathematically, infinity and negative infinity would always be 113 | exclusive. However, since they are defined values in the floating- 114 | point standard, they follow the same rules here as any other value, 115 | with regard to inclusivity or exclusivity in Range objects: 116 | 117 | >>> r = Range(include_start=True, include_end=False) 118 | >>> print(r) # [-inf, inf) 119 | >>> print(float('-inf') in r) # True 120 | >>> print(float('inf') in r) # False 121 | 122 | The Range class is hashable, meaning it can be used as the key in a 123 | `dict`. 124 | """ 125 | 126 | start: T 127 | end: T 128 | include_start: bool 129 | include_end: bool 130 | 131 | def __init__(self, *args: Union['Range', RangelikeString, T], 132 | start: T = _sentinel, end: T = _sentinel, 133 | include_start: bool = True, include_end: bool = False): 134 | """ __init__(self, *args) 135 | Constructs a new Range from `start` to `end`, or from an existing range. 136 | Is inclusive on the lower bound and exclusive on the upper bound by 137 | default, but can be made differently exclusive by setting the 138 | keywords `include_start` and `include_end` to `True` or `False`. 139 | 140 | Can be called in the fallowing ways: 141 | >>> Range(2, 5) # two arguments, start and end respectively 142 | >>> Range('[2..5)') # one argument, of type String - resolves to a numeric range. Use '..' or ',' as separator 143 | >>> Range(Range(2, 5)) # one argument, of type Range - copies the given Range 144 | >>> Range(start=2, end=5) # keyword arguments specify start and end. If not given, they default to -Inf/Inf 145 | >>> Range() # no arguments - infinite bounds by default 146 | 147 | If using the constructor `Range('[2, 5)')`, then the created range 148 | will be numeric. Otherwise, the Range may be of any comparable 149 | type - numbers, strings, datetimes, etc. The default Infinite 150 | bounds will safely compare with any type, even if that type would 151 | not normally be comparable. 152 | 153 | Positional arguments for `start` and `end` will take priority over 154 | keyword arguments for `start` and `end`, if both are present. 155 | 156 | Additionally, the kwargs `include_start` and `include_end` may be 157 | given to toggle the exclusivity of either end of the range. By 158 | default, `include_start = True` and `include_end = False`. If 159 | using the constructor `Range('[2, 5)')`, the type of bracket 160 | on either end indicates exclusivity - square bracket is inclusive 161 | and circle bracket is exclusive. This will take priority over the 162 | keyword arguments, if given. 163 | """ 164 | # process kwargs 165 | start = _InfiniteValue(negative=True) if start is _sentinel else start 166 | end = _InfiniteValue(negative=False) if end is _sentinel else end 167 | self.include_start = include_start 168 | self.include_end = include_end 169 | # Check how many positional args we got, and initialize accordingly 170 | if len(args) == 0: 171 | # with 0 positional args, initialize from kwargs directly 172 | rng = None 173 | elif len(args) == 1: 174 | # with 1 positional arg, initialize from existing range-like object 175 | if not args[0] and not isinstance(args[0], Range): 176 | raise ValueError("Cannot take a falsey non-Range value as only positional argument") 177 | rng = args[0] 178 | else: # len(args) >= 2: 179 | # with 2 positional args, initialize from given start and end values 180 | start = args[0] 181 | end = args[1] 182 | rng = None 183 | # initialize differently if given a Range vs if given a Start/End. 184 | if rng is not None: 185 | # case 1: construct from Range 186 | if isinstance(rng, Range): 187 | self.start = rng.start 188 | self.end = rng.end 189 | self.include_start = rng.include_start 190 | self.include_end = rng.include_end 191 | # case 3: construct from String 192 | elif isinstance(rng, str): 193 | pattern = r"(\[|\()\s*([^\s,]+)\s*(?:,|\.\.)\s*([^\s,]+)\s*(\]|\))" 194 | match = re.match(pattern, rng) 195 | try: 196 | # check for validity of open-bracket 197 | if match.group(1) == "[": 198 | self.include_start = True 199 | elif match.group(1) == "(": 200 | self.include_start = False 201 | else: 202 | raise AttributeError() 203 | # check for validity of close-bracket 204 | if match.group(4) == "]": 205 | self.include_end = True 206 | elif match.group(4) == ")": 207 | self.include_end = False 208 | else: 209 | raise AttributeError() 210 | # check start and end values 211 | self.start = float(match.group(2)) 212 | self.end = float(match.group(3)) 213 | if self.start.is_integer(): 214 | self.start = int(self.start) 215 | if self.end.is_integer(): 216 | self.end = int(self.end) 217 | except (AttributeError, IndexError): 218 | raise ValueError(f"Range '{rng}' was given in wrong format. Must be like '(start, end)' " + 219 | "where () means exclusive, [] means inclusive") 220 | except ValueError: 221 | raise ValueError("start and end must be numbers") 222 | # removed: construct from iterable representing start/end 223 | else: 224 | raise ValueError(f"cannot construct a new Range from an object of type '{type(rng)}'") 225 | else: 226 | # case 4 or 5: construct from positional args or kwargs 227 | self.start = start 228 | self.end = end 229 | try: 230 | if self.start > self.end: # start != float('-inf') and end != float('inf') and 231 | raise ValueError("start must be less than or equal to end") 232 | except TypeError as _: 233 | raise ValueError("start and end are not comparable types") 234 | # # if bounds are infinity, make sure those respective bounds are exclusive 235 | # if self.start in (float('-inf'), float('inf')): 236 | # self.include_start = False 237 | # if self.end in (float('-inf'), float('inf')): 238 | # self.include_end = False 239 | 240 | def isdisjoint(self, rng: Rangelike) -> bool: 241 | """ 242 | returns `False` if this range overlaps with the given range, 243 | and `True` otherwise. 244 | 245 | :param rng: range to check disjointness with 246 | :return: False if this range overlaps with the given range, True otherwise 247 | """ 248 | # if RangeSet, return that instead 249 | if isinstance(rng, ranges.RangeSet): 250 | return rng.isdisjoint(self) 251 | # convert other range to a format we can work with 252 | try: 253 | if not isinstance(rng, Range): 254 | rng = Range(rng) 255 | except ValueError: 256 | raise TypeError(str(rng) + " is not Range-like") 257 | # detect overlap 258 | rng_a, rng_b = (self, rng) if self < rng else (rng, self) 259 | return not ( 260 | rng_a == rng_b 261 | or (rng_a.end in rng_b if rng_a.end != rng_b.start else (rng_a.include_end and rng_b.include_start)) 262 | or (rng_b.start in rng_a if rng_a.end != rng_b.start else (rng_a.include_end and rng_b.include_start)) 263 | ) 264 | 265 | def union(self, rng: Rangelike) -> Union[Rangelike, None]: 266 | """ 267 | If this Range and the given Range overlap, then returns a Range that 268 | encompasses both of them. 269 | 270 | Returns `None` if the ranges don't overlap (if you need to, you can 271 | simply put both this Range and the given Range into a ranges.RangeSet). 272 | 273 | If the given range is actually a ranges.RangeSet, then returns a ranges.RangeSet. 274 | 275 | :param rng: range to find union with 276 | :return: A rangelike containing the union of this and the given rangelike, if they overlap or the given 277 | rangelike is a RangeSet. 278 | """ 279 | # if RangeSet, return union of that instead 280 | if isinstance(rng, ranges.RangeSet): 281 | return rng.union(self) 282 | # convert other range to a format we can really work with 283 | try: 284 | if not isinstance(rng, Range): 285 | rng = Range(rng) 286 | except ValueError: 287 | raise TypeError("Cannot merge a Range with a non-Range") 288 | # do the ranges overlap? 289 | rng_a, rng_b = (self, rng) if self < rng else (rng, self) 290 | if rng_a.isdisjoint(rng_b) and not (rng_a.end == rng_b.start and rng_a.include_end != rng_b.include_start): 291 | return None 292 | # merge 'em 293 | new_start = min((rng_a.start, rng_a.include_start), (rng_b.start, rng_b.include_start), 294 | key=lambda x: (x[0], not x[1])) 295 | new_end = max((rng_a.end, rng_a.include_end), (rng_b.end, rng_b.include_end)) 296 | return Range(start=new_start[0], end=new_end[0], include_start=new_start[1], include_end=new_end[1]) 297 | 298 | def intersection(self, rng: Rangelike) -> Union[Rangelike, None]: 299 | """ 300 | Returns a range representing the intersection between this range and 301 | the given range, or `None` if the ranges don't overlap at all. 302 | 303 | If the given range is actually a ranges.RangeSet, then returns a ranges.RangeSet. 304 | 305 | :param rng: range to find intersection with 306 | :return: a rangelike containing the intersection between this and the given rangelike 307 | """ 308 | # if a RangeSet, then return the intersection of that with this instead. 309 | if isinstance(rng, ranges.RangeSet): 310 | return rng.intersection(self) 311 | # convert other range to a format we can work with 312 | try: 313 | if not isinstance(rng, Range): 314 | rng = Range(rng) 315 | except ValueError: 316 | raise TypeError("Cannot overlap a Range with a non-Range") 317 | # do the ranges overlap? 318 | rng_a, rng_b = (self, rng) if self < rng else (rng, self) 319 | if rng_a.isdisjoint(rng_b): 320 | return None 321 | # compute parameters for new intersecting range 322 | # new_start = rng_b.start 323 | # new_include_start = new_start in rng_a 324 | # if rng_a.end < rng_b.end: 325 | # new_end = rng_a.end 326 | # new_include_end = new_end in rng_b 327 | # else: 328 | # new_end = rng_b.end 329 | # new_include_end = new_end in rng_a 330 | new_start = max((rng_a.start, rng_a.include_start), (rng_b.start, rng_b.include_start), 331 | key=lambda x: (x[0], not x[1])) 332 | new_end = min((rng_a.end, rng_a.include_end), (rng_b.end, rng_b.include_end)) 333 | # create and return new range 334 | return Range(start=new_start[0], end=new_end[0], include_start=new_start[1], include_end=new_end[1]) 335 | 336 | def difference(self, rng: Rangelike) -> Union[Rangelike, None]: 337 | """ 338 | Returns a range containing all elements of this range that are not 339 | within the other range, or `None` if this range is entirely consumed 340 | by the other range. 341 | 342 | If the other range is empty, or if this Range is entirely disjoint 343 | with it, then returns this Range (not a copy of this Range). 344 | 345 | If the other range is entirely consumed by this range, then returns 346 | a ranges.RangeSet containing `(lower_part, higher_part)`. 347 | 348 | If the given range is actually a ranges.RangeSet, then returns a ranges.RangeSet 349 | no matter what. 350 | 351 | :param rng: rangelike to find the difference with 352 | :return: a rangelike containing the difference between this and the given rangelike 353 | """ 354 | # if a RangeSet, then return the intersection of one of those with this instead. 355 | if isinstance(rng, ranges.RangeSet): 356 | return ranges.RangeSet(self).difference(rng) 357 | # convert other range to a workable format 358 | try: 359 | if not isinstance(rng, Range): 360 | rng = Range(rng) 361 | except ValueError: 362 | raise TypeError("Cannot diff a Range with a non-Range") 363 | # completely disjoint 364 | if rng.isempty(): 365 | return self 366 | elif self.isdisjoint(rng): 367 | return self 368 | # fully contained 369 | elif self in rng or self == rng: 370 | return None 371 | # fully contained (in the other direction) 372 | elif rng in self: 373 | lower = Range(start=self.start, end=rng.start, 374 | include_start=self.include_start, include_end=not rng.include_start) 375 | upper = Range(start=rng.end, end=self.end, 376 | include_start=not rng.include_end, include_end=self.include_end) 377 | # exclude empty ranges 378 | if lower.isempty(): 379 | return upper 380 | elif upper.isempty(): 381 | return lower 382 | else: 383 | return ranges.RangeSet(lower, upper) 384 | # lower portion of this range 385 | elif self < rng: 386 | new_rng = Range(start=self.start, end=rng.start, 387 | include_start=self.include_start, include_end=not rng.include_start) 388 | return None if new_rng.isempty() else new_rng 389 | # higher portion of this range 390 | else: # self > rng: 391 | new_rng = Range(start=rng.end, end=self.end, 392 | include_start=not rng.include_end, include_end=self.include_end) 393 | return None if new_rng.isempty() else new_rng 394 | 395 | def symmetric_difference(self, rng: Rangelike) -> Union[Rangelike, None]: 396 | """ 397 | Returns a Range (if possible) or ranges.RangeSet (if not) of ranges 398 | comprising the parts of this Range and the given Range that 399 | do not overlap. 400 | 401 | Returns `None` if the ranges overlap exactly (i.e. the symmetric 402 | difference is empty). 403 | 404 | If the given range is actually a ranges.RangeSet, then returns a ranges.RangeSet 405 | no matter what. 406 | 407 | :param rng: rangelike object to find the symmetric difference with 408 | :return: a rangelike object containing the symmetric difference between this and the given rangelike 409 | """ 410 | # if a RangeSet, then return the symmetric difference of one of those with this instead. 411 | if isinstance(rng, ranges.RangeSet): 412 | return rng.symmetric_difference(self) 413 | # convert to range so we can work with it 414 | try: 415 | if not isinstance(rng, Range): 416 | rng = Range(rng) 417 | except ValueError: 418 | raise TypeError("Cannot diff a Range with a non-Range") 419 | # if ranges are equal 420 | if self == rng: 421 | return None 422 | # otherwise, get differences 423 | diff_a = self.difference(rng) 424 | diff_b = rng.difference(self) 425 | # create dummy symmetric difference object 426 | if isinstance(diff_a, ranges.RangeSet): 427 | # diffA has 2 elements, therefore diffB has 0 elements, e.g. (1,4) ^ (2,3) -> {(1,2], [3,4)} 428 | return diff_a 429 | elif isinstance(diff_b, ranges.RangeSet): 430 | # diffB has 2 elements, therefore diffA has 0 elements, e.g. (2,3) ^ (1,4) -> {(1,2], [3,4)} 431 | return diff_b 432 | elif diff_a is not None and diff_b is not None: 433 | # diffA has 1 element, diffB has 1 element, e.g. (1,3) ^ (2,4) -> {(1,2], [3,4)} 434 | return ranges.RangeSet(diff_a, diff_b) 435 | elif diff_a is not None: 436 | # diffA has 1 element, diffB has 0 elements, e.g. (1,4) ^ (1,2) -> [2,4) 437 | return diff_a 438 | else: 439 | # diffA has 0 elements, diffB has 1 element, e.g. (3,4) ^ (1,4) -> (1,3] 440 | return diff_b 441 | 442 | def complement(self) -> 'ranges.RangeSet': 443 | """ 444 | Returns a RangeSet containing all items not present in this Range 445 | 446 | :return: the complement of this Range 447 | """ 448 | return ranges.RangeSet(Range()) - self 449 | 450 | def clamp(self, value: T) -> T: 451 | """ 452 | If this Range includes the given value, then return the value. Otherwise, return whichever 453 | bound is closest to the value. 454 | 455 | :param value: value to restrict to the borders of this range 456 | :return: the given value if it is in the range, or whichever border is closest to the value otherwise 457 | """ 458 | if value in self: 459 | return value 460 | elif value >= self.end: 461 | return self.end 462 | elif value <= self.start: 463 | return self.start 464 | else: 465 | raise ValueError("Cannot clamp() the given value to this range") 466 | 467 | def isempty(self) -> bool: 468 | """ 469 | Returns `True` if this range is empty (it contains no values), and 470 | `False` otherwise. 471 | 472 | In essence, will only return `True` if `start == end` and either end 473 | is exclusive. 474 | 475 | :return: True if this range contains no values. False otherwise 476 | """ 477 | return self.start == self.end and (not self.include_start or not self.include_end) 478 | 479 | def copy(self) -> 'Range': 480 | """ 481 | Copies this range, without modifying it. 482 | 483 | :return: a copy of this object, identical to calling `Range(self)` 484 | """ 485 | return Range(self) 486 | 487 | def length(self) -> Union[T, Any]: 488 | """ 489 | Returns the size of this range (`end - start`), irrespective of whether 490 | either end is inclusive. 491 | 492 | If end and start are different types and are not naturally compatible 493 | for subtraction (e.g. `float` and `Decimal`), then first tries to 494 | coerce `start` to `end`'s class, and if that doesn't work then tries 495 | to coerce `end` to `start`'s class. 496 | 497 | Raises a `TypeError` if start and end are the same and not compatible 498 | for subtraction, or if type coercion fails. 499 | 500 | Custom types used as Range endpoints are expected to raise `TypeError`, 501 | `ArithmeticError`, or `ValueError` on failed subtraction. If not, 502 | whatever exception they raise will improperly handled by this method, 503 | and will thus be raised instead. 504 | 505 | :return: `end` - `start` for this range 506 | """ 507 | # try normally 508 | with suppress(TypeError, ArithmeticError, ValueError): 509 | return self.end - self.start 510 | 511 | if not isinstance(self.start, self.end.__class__): 512 | # try one-way conversion 513 | with suppress(TypeError, ArithmeticError, ValueError): 514 | return self.end - self.end.__class__(self.start) 515 | # try the other-way conversion 516 | with suppress(TypeError, ArithmeticError, ValueError): 517 | return self.start.__class__(self.end) - self.start 518 | 519 | raise TypeError(f"Range of {self.start.__class__} to {self.end.__class__} has no defined length") 520 | 521 | def isinfinite(self) -> bool: 522 | """ 523 | Returns True if this Range has a negative bound equal to -Inf or a positive bound equal to +Inf 524 | 525 | :return True if this Range has a negative bound equal to -Inf or a positive bound equal to +Inf 526 | """ 527 | return self.start == -Inf or self.end == Inf 528 | 529 | def _above_start(self, item: Union['Range', T]) -> bool: 530 | """ 531 | Returns True if the given item is greater than or equal to this Range's start, 532 | depending on whether this Range is set to include the start. 533 | If the given item is a Range, tests that range's .start. 534 | 535 | :param item: item to test against start 536 | :return: True if the given item is greater than or equal to this Range's start 537 | """ 538 | if isinstance(item, Range): 539 | if self.include_start or self.include_start == item.include_start: 540 | return item.start >= self.start 541 | else: 542 | return item.start > self.start 543 | if self.include_start: 544 | return item >= self.start 545 | else: 546 | return item > self.start 547 | 548 | def _below_end(self, item: Union['Range', T]) -> bool: 549 | """ 550 | Returns True if the given item is less than or equal to this Range's end, 551 | depending on whether this Range is set to include the end. 552 | If the given item is a Range, tests that range's .end. 553 | 554 | :param item: item to test against end 555 | :return: True if the given item is less than or equal to this Range's end 556 | """ 557 | if isinstance(item, Range): 558 | if self.include_end or self.include_end == item.include_end: 559 | return item.end <= self.end 560 | else: 561 | return item.end < self.end 562 | if self.include_end: 563 | return item <= self.end 564 | else: 565 | return item < self.end 566 | 567 | def __eq__(self, obj: Rangelike) -> bool: 568 | """ 569 | Compares the start and end of this range to the other range, along with 570 | inclusivity at either end. Returns `True` if everything is the same, or 571 | `False` otherwise. If the other object is a RangeSet, uses the other object's 572 | __eq__() instead. Always returns False if the other object is not rangelike. 573 | 574 | :return: True if the given object is equal to this Range. 575 | """ 576 | if isinstance(obj, ranges.RangeSet): 577 | return obj == self 578 | try: 579 | if not isinstance(obj, Range): 580 | obj = Range(obj) 581 | return (self.start, self.end, self.include_start, self.include_end) == \ 582 | (obj.start, obj.end, obj.include_start, obj.include_end) 583 | except (AttributeError, ValueError): 584 | return False 585 | 586 | def __lt__(self, obj: Rangelike) -> bool: 587 | """ 588 | Used for ordering, not for subranging/subsetting. Compares attributes in 589 | the following order, returning True/False accordingly: 590 | 1. start 591 | 2. include_start (inclusive < exclusive) 592 | 3. end 593 | 4. include_end (exclusive < inclusive) 594 | 595 | :return: True if this range should be ordered before the given rangelike object, False otherwise 596 | """ 597 | if isinstance(obj, ranges.RangeSet): 598 | return obj > self 599 | try: 600 | if not isinstance(obj, Range): 601 | obj = Range(obj) 602 | return (self.start, not self.include_start, self.end, self.include_end) < \ 603 | (obj.start, not obj.include_start, obj.end, obj.include_end) 604 | except (AttributeError, ValueError, TypeError): 605 | if isinstance(obj, Range): 606 | raise TypeError("'<' not supported between " 607 | f"'Range({self.start.__class__.__name__}, {self.end.__class__.__name__})' and " 608 | f"'Range({obj.start.__class__.__name__}, {obj.end.__class__.__name__})'") 609 | else: 610 | raise TypeError("'<' not supported between instances of " 611 | f"'{self.__class__.__name__}' and '{obj.__class__.__name__}'") 612 | 613 | def __gt__(self, obj: Rangelike) -> bool: 614 | """ 615 | Used for ordering, not for subranging/subsetting. Compares attributes in 616 | the following order, returning True/False accordingly: 617 | 1. start 618 | 2. include_start (inclusive < exclusive) 619 | 3. end 620 | 4. include_end (exclusive < inclusive) 621 | 622 | :return: True if this range should be ordered after the given rangelike object, False otherwise 623 | """ 624 | if isinstance(obj, ranges.RangeSet): 625 | return obj < self 626 | try: 627 | if not isinstance(obj, Range): 628 | obj = Range(obj) 629 | return (self.start, not self.include_start, self.end, self.include_end) > \ 630 | (obj.start, not obj.include_start, obj.end, obj.include_end) 631 | except (AttributeError, ValueError, TypeError): 632 | if isinstance(obj, Range): 633 | raise TypeError("'<' not supported between " 634 | f"'Range({self.start.__class__.__name__}, {self.end.__class__.__name__})' and " 635 | f"'Range({obj.start.__class__.__name__}, {obj.end.__class__.__name__})'") 636 | else: 637 | raise TypeError("'<' not supported between instances of " 638 | f"'{self.__class__.__name__}' and '{obj.__class__.__name__}'") 639 | 640 | def __ge__(self, obj: Rangelike) -> bool: 641 | """ 642 | Used for ordering, not for subranging/subsetting. See docstrings for 643 | __eq__() and __gt__(). 644 | 645 | :return: True if this range is equal to or should be ordered after the given rangelike object. False otherwise. 646 | """ 647 | return self > obj or self == obj 648 | 649 | def __le__(self, obj: Rangelike) -> bool: 650 | """ 651 | Used for ordering, not for subranging/subsetting. See docstrings for 652 | __eq__() and __lt__(). 653 | 654 | :return: True if this range is equal to or should be ordered before the given rangelike object. False otherwise. 655 | """ 656 | return self < obj or self == obj 657 | 658 | def __ne__(self, obj: Rangelike) -> bool: 659 | """ 660 | See docstring for __eq__(). Returns the opposite of that. 661 | 662 | :return: False if this range is equal to the given rangelike object. True otherwise. 663 | """ 664 | return not self == obj 665 | 666 | def __or__(self, other: Rangelike) -> Union[Rangelike, None]: 667 | """ 668 | Equivalent to self.union(other) 669 | 670 | :param other: rangelike to union 671 | :return: a rangelike object containing both this range and the given rangelike object 672 | """ 673 | return self.union(other) 674 | 675 | def __and__(self, other: Rangelike) -> Union[Rangelike, None]: 676 | """ 677 | Equivalent to self.intersect(other) 678 | 679 | :param other: rangelike to intersect 680 | :return: a rangelike object containing the overlap between this range and the given rangelike object 681 | """ 682 | return self.intersection(other) 683 | 684 | def __sub__(self, other: Rangelike) -> Union[Rangelike, None]: 685 | """ 686 | Equivalent to self.difference(other) 687 | 688 | :param other: rangelike to find difference with 689 | :return: a rangelike object containing everything in this range but not in the given rangelike object 690 | """ 691 | return self.difference(other) 692 | 693 | def __xor__(self, other: Rangelike) -> Union[Rangelike, None]: 694 | """ 695 | Equivalent to self.symmetric_difference(other) 696 | 697 | :param other: rangelike to find symmetric difference with 698 | :return: a rangelike object containing everything in either this range or the given rangelike, but not both 699 | """ 700 | return self.symmetric_difference(other) 701 | 702 | def __invert__(self) -> 'RangeSet': 703 | """ 704 | Equivalent to self.complement() 705 | 706 | :return: a RangeSet containing everything that is not in this Range 707 | """ 708 | return self.complement() 709 | 710 | def __contains__(self, item: T) -> bool: 711 | """ 712 | Returns `True` if the given item is inside the bounds of this range, 713 | `False` if it isn't. 714 | 715 | If the given item isn't comparable to this object's start and end 716 | objects, then tries to convert the item to a Range, and returns 717 | `True` if it is completely contained within this range, `False` 718 | if it isn't. 719 | 720 | A Range always contains itself. 721 | 722 | :param item: item to check if is contained 723 | :return: True if the item is within the bounds of this range. False otherwise 724 | """ 725 | if self == item: 726 | return True 727 | if isinstance(item, ranges.RangeSet): 728 | return all(rng in self for rng in item.ranges()) 729 | else: 730 | try: 731 | return self._above_start(item) and self._below_end(item) 732 | except TypeError: 733 | with suppress(ValueError): 734 | rng_item = Range(item) 735 | return rng_item.start in self and rng_item.end in self 736 | raise TypeError(f"'{item}' is not comparable with this Range's start and end") 737 | 738 | def __hash__(self): 739 | return hash((self.start, self.end, self.include_start, self.include_end)) 740 | 741 | def __str__(self): 742 | return f"{'[' if self.include_start else '('}{str(self.start)}, " \ 743 | f"{str(self.end)}{']' if self.include_end else ')'}" 744 | 745 | def __repr__(self): 746 | return f"Range{'[' if self.include_start else '('}{repr(self.start)}, " \ 747 | f"{repr(self.end)}{']' if self.include_end else ')'}" 748 | 749 | def __bool__(self) -> bool: 750 | """ 751 | :return: False if this range is empty, True otherwise 752 | """ 753 | return not self.isempty() 754 | -------------------------------------------------------------------------------- /ranges/RangeDict.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from operator import is_ 3 | from ._helper import _UnhashableFriendlyDict, _LinkedList, _is_iterable_non_string, Rangelike 4 | from .Range import Range 5 | from .RangeSet import RangeSet 6 | from typing import Iterable, Union, Any, TypeVar, List, Tuple, Dict, Tuple 7 | 8 | T = TypeVar('T', bound=Any) 9 | V = TypeVar('V', bound=Any) 10 | 11 | 12 | class RangeDict: 13 | """ RangeDict(self, iterable, *, identity=False) 14 | A class representing a dict-like structure where continuous ranges 15 | correspond to certain values. For any item given to lookup, the 16 | value obtained from a RangeDict will be the one corresponding to 17 | the first range into which the given item fits. Otherwise, RangeDict 18 | provides a similar interface to python's built-in dict. 19 | 20 | A RangeDict can be constructed in one of four ways: 21 | 22 | >>> # Empty 23 | >>> a = RangeDict() 24 | 25 | >>> # From an existing RangeDict object 26 | >>> b = RangeDict(a) 27 | 28 | >>> # From a dict that maps Ranges to values 29 | >>> c = RangeDict({ 30 | ... Range('a', 'h'): "First third of the lowercase alphabet", 31 | ... Range('h', 'p'): "Second third of the lowercase alphabet", 32 | ... Range('p', '{'): "Final third of the lowercase alphabet", 33 | ... }) 34 | >>> print(c['brian']) # First third of the lowercase alphabet 35 | >>> print(c['king arthur']) # Second third of the lowercase alphabet 36 | >>> print(c['python']) # Final third of the lowercase alphabet 37 | 38 | >>> # From an iterable of 2-tuples, like a regular dict 39 | >>> d = RangeDict([ 40 | ... (Range('A', 'H'), "First third of the uppercase alphabet"), 41 | ... (Range('H', 'P'), "Second third of the uppercase alphabet"), 42 | ... (Range('P', '['), "Final third of the uppercase alphabet"), 43 | ... ]) 44 | 45 | A RangeDict cannot be constructed from an arbitrary number of positional 46 | arguments or keyword arguments. 47 | 48 | RangeDicts are mutable, so new range correspondences can be added 49 | at any time, with Ranges or RangeSets acting like the keys in a 50 | normal dict/hashtable. New keys must be of type Range or RangeSet, 51 | or they must be able to be coerced into a RangeSet. Given 52 | keys are also copied before they are added to a RangeDict. 53 | 54 | Adding a new range that overlaps with an existing range will 55 | make it so that the value returned for any given number will be 56 | the one corresponding to the most recently-added range in which 57 | it was found (Ranges are compared by `start`, `include_start`, `end`, 58 | and `include_end` in that priority order). Order of insertion is 59 | important. 60 | 61 | The RangeDict constructor, and the `.update()` method, insert elements 62 | in order from the iterable they came from. As of python 3.7+, dicts 63 | retain the insertion order of their arguments, and iterate in that 64 | order - this is respected by this data structure. Other iterables, 65 | like lists and tuples, have order built-in. Be careful about using 66 | sets as arguments, since they have no guaranteed order. 67 | 68 | Be very careful about adding a range from -infinity to +infinity. 69 | If defined using the normal Range constructor without any start/end 70 | arguments, then that Range will by default accept any value (see 71 | Range's documentation for more info). However, the first non-infinite 72 | Range added to the RangeDict will overwrite part of the infinite Range, 73 | and turn it into a Range of that type only. As a result, other types 74 | that the infinite Range may have accepted before, will no longer work: 75 | 76 | >>> e = RangeDict({Range(include_end=True): "inquisition"}) 77 | >>> print(e) # {{[-inf, inf)}: inquisition} 78 | >>> print(e.get(None)) # inquisition 79 | >>> print(e.get(3)) # inquisition 80 | >>> print(e.get("holy")) # inquisition 81 | >>> print(e.get("spanish")) # inquisition 82 | >>> 83 | >>> e[Range("a", "m")] = "grail" 84 | >>> 85 | >>> print(e) # {{[-inf, a), [m, inf)}: inquisition, {[a, m)}: grail} 86 | >>> print(e.get("spanish")) # inquisition 87 | >>> print(e.get("holy")) # grail 88 | >>> print(e.get(3)) # KeyError 89 | >>> print(e.get(None)) # KeyError 90 | 91 | In general, unless something has gone wrong, the RangeDict will not 92 | include any empty ranges. Values will disappear if there are not 93 | any keys that map to them. Adding an empty Range to the RangeDict 94 | will not trigger an error, but will have no effect. 95 | 96 | By default, the range set will determine value uniqueness by equality 97 | (`==`), not by identity (`is`), and multiple rangekeys pointing to the 98 | same value will be compressed into a single RangeSet pointed at a 99 | single value. This is mainly meaningful for values that are mutable, 100 | such as `list`s or `set`s. 101 | If using assignment operators besides the generic `=` (`+=`, `|=`, etc.) 102 | on such values, be warned that the change will reflect upon the entire 103 | rangeset. 104 | 105 | >>> # [{3}] == [{3}] is True, so the two ranges are made to point to the same object 106 | >>> f = RangeDict({Range(1, 2): {3}, Range(4, 5): {3}}) 107 | >>> print(f) # {{[1, 2), [4, 5)}: {3}} 108 | >>> 109 | >>> # f[1] returns the {3}. When |= is used, this object changes to {3, 4} 110 | >>> f[Range(1, 2)] |= {4} 111 | >>> # since the entire rangeset is pointing at the same object, the entire range changes 112 | >>> print(f) # {{[1, 2), [4, 5)}: {3, 4}} 113 | 114 | This is because `dict[value] = newvalue` calls `dict.__setitem__()`, whereas 115 | `dict[value] += item` instead calls `dict[value].__iadd__()` instead. 116 | To make the RangeDict use identity comparison instead, construct it with the 117 | keyword argument `identity=True`, which should help: 118 | 119 | >>> # `{3} is {3}` is False, so the two ranges don't coalesce 120 | >>> g = RangeDict({Range(1, 2): {3}, Range(4, 5): {3}}, identity=True) 121 | >>> print(g) # {{[1, 2)}: {3}, {[4, 5)}: {3}} 122 | 123 | To avoid the problem entirely, you can also simply not mutate mutable values 124 | that multiple rangekeys may refer to, substituting non-mutative operations: 125 | 126 | >>> h = RangeDict({Range(1, 2): {3}, Range(4, 5): {3}}) 127 | >>> print(h) # {{[1, 2), [4, 5)}: {3}} 128 | >>> h[Range(1, 2)] = h[Range(1, 2)] | {4} 129 | >>> print(h) # {{[4, 5)}: {3}, {[1, 2)}: {3, 4}} 130 | """ 131 | # sentinel for checking whether an arg was passed, where anything is valid including None 132 | _sentinel = object() 133 | 134 | def __init__(self, iterable: Union['RangeDict', Dict[Rangelike, V], Iterable[Tuple[Rangelike, V]]] = _sentinel, 135 | *, identity=False): 136 | """ __init__(iterable, *, identity=False) 137 | Initialize a new RangeDict from the given iterable. The given iterable 138 | may be either a RangeDict (in which case, a copy will be created), 139 | a regular dict with all keys able to be converted to Ranges, or an 140 | iterable of 2-tuples (range, value). 141 | 142 | If the argument `identity=True` is given, the RangeDict will use `is` instead 143 | of `==` when it compares multiple rangekeys with the same associated value to 144 | possibly merge them. 145 | 146 | :param iterable: Optionally, an iterable from which to source keys - either a RangeDict, a regular dict 147 | with Rangelike objects as keys, or an iterable of (range, value) tuples. 148 | :param identity: optionally, a toggle to use identity instead of equality when determining key-value 149 | similarity. By default, uses equality, but will use identity instead if True is passed. 150 | """ 151 | # Internally, RangeDict has two data structures 152 | # _values is a dict {value: [rangeset, ...], ..., '_sentinel': [(value: [rangeset, ...]), ...]} 153 | # The sentinel allows the RangeDict to accommodate unhashable types. 154 | # _ranges is a list-of-lists, [[(intrangeset1, value1), (intrangeset2, value2), ...], 155 | # [(strrangeset1, value1), (strrangeset2, value2), ...], 156 | # ...] 157 | # where each inner list is a list of (RangeSet, corresponding_value) tuples. 158 | # Each inner list corresponds to a different, mutually-incomparable, type of Range. 159 | # We use _values to cross-reference with while adding new ranges, to avoid having to search the entire 160 | # _ranges for the value we want to point to. 161 | # Meanwhile, _ranges is a list-of-lists instead of just a list, so that we can accommodate ranges of 162 | # different types (e.g. a RangeSet of ints and a RangeSet of strings) pointing to the same values. 163 | self._values = _UnhashableFriendlyDict() 164 | if identity: 165 | self._values._operator = is_ 166 | if iterable is RangeDict._sentinel: 167 | self._rangesets = _LinkedList() 168 | elif isinstance(iterable, RangeDict): 169 | self._values.update({val: rngsets[:] for val, rngsets in iterable._values.items()}) 170 | self._rangesets = _LinkedList([rngset.copy() for rngset in iterable._rangesets]) 171 | elif isinstance(iterable, dict): 172 | self._rangesets = _LinkedList() 173 | for rng, val in iterable.items(): 174 | if _is_iterable_non_string(rng): 175 | for r in rng: 176 | self.add(r, val) 177 | else: 178 | self.add(rng, val) 179 | else: 180 | try: 181 | assert(_is_iterable_non_string(iterable)) # creative method of avoiding code reuse! 182 | self._rangesets = _LinkedList() 183 | for rng, val in iterable: 184 | # this should not produce an IndexError. It produces a TypeError instead. 185 | # (or a ValueError in case of too many to unpack. Which is fine because it screens for 3-tuples) 186 | if _is_iterable_non_string(rng): 187 | # this allows constructing with e.g. rng=[Range(1, 2), Range('a', 'b')], which makes sense 188 | for r in rng: 189 | self.add(r, val) 190 | else: 191 | self.add(rng, val) 192 | except (TypeError, ValueError, AssertionError): 193 | raise ValueError("Expected a dict, RangeDict, or iterable of 2-tuples") 194 | self._values[RangeDict._sentinel] = [] 195 | self.popempty() 196 | 197 | def add(self, rng: Rangelike, value: V) -> None: 198 | """ 199 | Add the single given Range/RangeSet to correspond to the given value. 200 | If the given Range overlaps with a Range that is already contained 201 | within this RangeDict, then the new range takes precedence. 202 | 203 | To add multiple Ranges of the same type, pack them into a RangeSet 204 | and pass that. 205 | 206 | To add a list of multiple Ranges of different types, use `.update()` 207 | instead. Using this method instead will produce a `TypeError`. 208 | 209 | If an empty Range is given, then this method does nothing. 210 | 211 | :param rng: Rangekey to add 212 | :param value: value to add corresponding to the given Rangekey 213 | """ 214 | # copy the range and get it into an easy-to-work-with form 215 | try: 216 | rng = RangeSet(rng) 217 | except TypeError: 218 | raise TypeError("argument 'rng' for .add() must be able to be converted to a RangeSet") 219 | if rng.isempty(): 220 | return 221 | # special case: if we try to add a perfectly infinite range, then completely empty this rangeset 222 | # and add this as the only element (since it will subsume everything else anyway) 223 | if rng.containseverything(): 224 | self._rangesets.clear() 225 | self._values.clear() 226 | # first, remove this range from any existing range 227 | short_circuit = False 228 | for rngsetlist in self._rangesets: 229 | # rngsetlist is a tuple (_LinkedList(ranges), value) 230 | for rngset in rngsetlist: 231 | # rngset 232 | with suppress(TypeError): 233 | rngset[0].discard(rng) 234 | short_circuit = True # (naively) assume only one type of rngset will be compatible 235 | if short_circuit: 236 | self.popempty() 237 | break 238 | # then, add it back in depending on whether it shares an existing value or not. 239 | if value in self._values: 240 | # duplicate value. More than one range must map to it. 241 | existing_rangesets = self._values[value] 242 | # existing_rangesets is a list (not _LinkedList) of RangeSets that correspond to value. 243 | # if there's already a whole RangeSet pointing to value, then simply add to that RangeSet 244 | for rngset in existing_rangesets: 245 | if len(existing_rangesets) > 1 and rngset.containseverything(): 246 | continue 247 | with suppress(TypeError): 248 | # ...once we find the RangeSet of the right type 249 | rngset.add(rng) 250 | # And then bubble it into place in whichever _LinkedList would have contained it. 251 | # This is one empty list traversal for every non-modified _LinkedList, and one gnomesort 252 | # for the one we really want. A little time loss but not that much. Especially not 253 | # any extra timeloss for single-typed RangeDicts. 254 | self._sort_ranges() 255 | self._coalesce_infinite_ranges() 256 | # And short-circuit, since we've already dealt with the complications and don't need to 257 | # do any further modification of _values or _rangesets 258 | return 259 | # if we didn't find a RangeSet of the right type, then we must add rng as a new RangeSet of its own type. 260 | # add a reference in _values 261 | self._values[value].append(rng) 262 | else: 263 | # new value. This is easy, we just need to add a value for it: 264 | self._values[value] = [rng] 265 | # Now that we've added our new RangeSet into _values, we need to make sure it's accounted for in _rangesets 266 | # we will first try to insert it into all our existing rangesets 267 | for rngsetlist in self._rangesets: 268 | # rngsetlist is a _LinkedList of (RangeSet, value) tuples 269 | # [(rangeset0, value0), (rangeset1, value1), ...] 270 | with suppress(TypeError): 271 | # "try" == "assess comparability with the rest of the RangeSets in this _LinkedList". 272 | # This is checked via trying to execute a dummy comparison with the first RangeSet in this category, 273 | # and seeing if it throws a TypeError. 274 | # Though it's kinda silly, this is probably the best way to handle this. See: 275 | # https://stackoverflow.com/q/57717100/2648811 276 | _ = rng < rngsetlist[0][0] 277 | # If it doesn't raise an error, then it's comparable and we're good. 278 | # Add it, bubble it to sorted order via .gnomesort(), and return. 279 | rngsetlist.append((rng, value)) 280 | rngsetlist.gnomesort() 281 | self._coalesce_infinite_ranges() 282 | return 283 | # if no existing rangeset accepted it, then we need to add one. 284 | # singleton _LinkedList containing just (rng, value), appended to self._rangesets 285 | self._rangesets.append(_LinkedList(((rng, value),))) 286 | self._coalesce_infinite_ranges() 287 | 288 | def adddefault(self, rng: Rangelike, value: V) -> None: 289 | """ 290 | Add a single Range/Rangeset to correspond to the given value. 291 | If the given range overlaps with an existing rangekey, the 292 | existing rangekey takes precedence. 293 | 294 | To add a list of multiple Ranges of different types, use `.update()` 295 | instead. Using this method instead will produce a `TypeError`. 296 | 297 | If an empty Range is given, then this method does nothing. 298 | 299 | If a range is given that contains everything (regardless of whether 300 | its infinite endpoints are inclusive or exclusive), then the 'default' 301 | value for all types of ranges currently contained in this RangeDict 302 | will be made to correspond to the given value (no ranges of new types 303 | will be added, and types that were previously incompatible with this 304 | RangeDict's contents will remain that way). 305 | 306 | :param rng: Rangekey to add 307 | :param value: value to add corresponding to the given Rangekey 308 | """ 309 | # copy the range and get it into an easy-to-work-with form 310 | try: 311 | rng = RangeSet(rng) 312 | except TypeError: 313 | raise TypeError("argument 'rng' for .adddefault() must be able to be converted to a RangeSet") 314 | if rng.isempty(): 315 | return 316 | # special case: if the range is both infinite and typeless 317 | # in this case, instead of finding all differences from rng, we must 318 | # find all differences _for each type of rangeset_ in this dict. 319 | if rng.containseverything(): 320 | if self.isempty(): 321 | self.add(rng, value) 322 | return 323 | i = 0 324 | while i < len(self._rangesets): 325 | rngsetlist = self._rangesets[i] 326 | i += 1 327 | # rngsetlist is a _LinkedList of (RangeSet, value) tuples 328 | # [(rangeset0, value0), (rangeset1, value1), ...] 329 | r = rng.copy() 330 | for rngset, _ in rngsetlist: 331 | r.difference_update(rngset) 332 | if r.isempty(): 333 | break 334 | if not r.isempty(): 335 | self.add(r, value) 336 | i -= 1 337 | return 338 | # if range is not infinite and typeless, then 339 | # remove all ranges that currently exist from the given range 340 | # (ignoring ranges that conflict in type) 341 | for rngsetlist in self._rangesets: 342 | # rngsetlist is a _LinkedList of (RangeSet, value) tuples 343 | # [(rangeset0, value0), (rangeset1, value1), ...] 344 | try: 345 | for rngset, _ in rngsetlist: 346 | rng.difference_update(rngset) 347 | if rng.isempty(): 348 | return 349 | except TypeError: 350 | continue 351 | # now that we can be confident the given range doesn't overlap any existing ranges, 352 | # add it as normal 353 | self.add(rng, value) 354 | 355 | def update(self, iterable: Union['RangeDict', Dict[Rangelike, V], Iterable[Tuple[Rangelike, V]]]) -> None: 356 | """ 357 | Adds the contents of the given iterable (either another RangeDict, a 358 | `dict` mapping Range-like objects to values, or a list of 2-tuples 359 | `(range-like, value)`) to this RangeDict. 360 | 361 | :param iterable: An iterable containing keys and values to add to this RangeDict 362 | """ 363 | # coerce to RangeDict and add that 364 | if not isinstance(iterable, RangeDict): 365 | iterable = RangeDict(iterable) 366 | for value, rangesets in iterable._values.items(): 367 | for rngset in rangesets: 368 | self.add(rngset, value) 369 | 370 | def getitem(self, item: T) -> Tuple[List[RangeSet], RangeSet, Range, V]: 371 | """ 372 | Returns both the value corresponding to the given item, the Range 373 | containing it, and the set of other contiguous ranges that would 374 | have also yielded the same value, as a 4-tuple 375 | `([RangeSet1, Rangeset2, ...], RangeSet, Range, value)`. 376 | 377 | In reverse order, that is 378 | - the value corresponding to item 379 | - the single continuous range directly containing the item 380 | - the RangeSet directly containing the item and corresponding 381 | to the value 382 | - a list of all RangeSets (of various non-mutually-comparable 383 | types) that all correspond to the value. Most of the time, 384 | this will be a single-element list, if only one type of Range 385 | is used in the RangeDict. Otherwise, if ranges of multiple 386 | types (e.g. int ranges, string ranges) correspond to the same 387 | value, this list will contain all of them. 388 | 389 | Using `.get()`, `.getrange()`, `.getrangeset()`, or 390 | `.getrangesets()` to isolate just one of those return values is 391 | usually easier. This method is mainly used internally. 392 | 393 | Raises a `KeyError` if the desired item is not found. 394 | 395 | :param item: item to search for 396 | :return: a 4-tuple (keys with same value, containing RangeSet, containing Range, value) 397 | """ 398 | for rngsets in self._rangesets: 399 | # rngsets is a _LinkedList of (RangeSet, value) tuples 400 | for rngset, value in rngsets: 401 | try: 402 | rng = rngset.getrange(item) 403 | return self._values[value], rngset, rng, value 404 | except IndexError: 405 | # try RangeSets of the same type, corresponding to other values 406 | continue 407 | except TypeError: 408 | # try RangeSets of a different type 409 | break 410 | raise KeyError(f"'{item}' was not found in any range") 411 | 412 | def getrangesets(self, item: T) -> List[RangeSet]: 413 | """ 414 | Finds the value to which the given item corresponds in this RangeDict, 415 | and then returns a list of all RangeSets in this RangeDict that 416 | correspond to that value. 417 | 418 | Most of the time, this will be a single-element list, if only one 419 | type of Range is used in the RangeDict. Otherwise, if ranges of 420 | multiple types (e.g. int ranges, string ranges) correspond to the 421 | same value, this list will contain all of them. 422 | 423 | Raises a `KeyError` if the given item is not found. 424 | 425 | :param item: item to search for 426 | :return: all RangeSets in this RangeDict that correspond to the same value as the given item 427 | """ 428 | return self.getitem(item)[0] 429 | 430 | def getrangeset(self, item: T) -> RangeSet: 431 | """ 432 | Finds the value to which the given item corresponds in this RangeDict, 433 | and then returns the RangeSet containing the given item that 434 | corresponds to that value. 435 | 436 | To find other RangeSets of other types that correspond to the same 437 | value, use `.getrangesets()` instead. 438 | 439 | Raises a `KeyError` if the given item is not found. 440 | 441 | :param item: item to search for 442 | :return: the RangeSet key containing the given item 443 | """ 444 | return self.getitem(item)[1] 445 | 446 | def getrange(self, item: T) -> Range: 447 | """ 448 | Finds the value to which the given item corresponds in this RangeDict, 449 | and then returns the single contiguous range containing the given item 450 | that corresponds to that value. 451 | 452 | To find the RangeSet of all Ranges that correspond to that item, 453 | use `.getrangeset()` instead. 454 | 455 | Raises a `KeyError` if the given item is not found. 456 | 457 | :param item: item to search for 458 | :return: the Range most directly containing the given item 459 | """ 460 | return self.getitem(item)[2] 461 | 462 | def get(self, item: T, default: Any = _sentinel) -> Union[V, Any]: 463 | """ 464 | Returns the value corresponding to the given item, based on 465 | the most recently-added Range containing it. 466 | 467 | The `default` argument is optional. 468 | Like Python's built-in `dict`, if `default` is given, returns that if 469 | `item` is not found. 470 | Otherwise, raises a `KeyError`. 471 | 472 | :param item: item to search for 473 | :param default: optionally, a value to return, if item is not found 474 | (if not provided, raises a KeyError if not found) 475 | :return: the value corrsponding to the item, or default if item is not found 476 | """ 477 | try: 478 | return self.getitem(item)[3] 479 | except KeyError: 480 | if default is not RangeDict._sentinel: 481 | return default 482 | raise 483 | 484 | def getoverlapitems(self, rng: Rangelike) -> List[Tuple[List[RangeSet], RangeSet, V]]: 485 | """ 486 | Returns a list of 3-tuples 487 | [([RangeSet1, ...], RangeSet, value), ...] 488 | corresponding to every distinct rangekey of this RangeDict that 489 | overlaps the given range. 490 | 491 | In reverse order, for each tuple, that is 492 | - the value corresponding to the rangeset 493 | - the RangeSet corresponding to the value that intersects the given range 494 | - a list of all RangeSets (of various non-mutually-comparable 495 | types) that all correspond to the value. Most of the time, 496 | this will be a single-element list, if only one type of Range 497 | is used in the RangeDict. Otherwise, if ranges of multiple 498 | types (e.g. int ranges, string ranges) correspond to the same 499 | value, this list will contain all of them. 500 | 501 | Using `.getoverlap()`, `.getoverlapranges()`, or 502 | `.getoverlaprangesets()` 503 | to isolate just one of those return values is 504 | usually easier. This method is mainly used internally. 505 | 506 | :param rng: Rangelike to search for 507 | :return: a list of 3-tuples (Rangekeys with same value, containing RangeSet, value) 508 | """ 509 | ret = [] 510 | for rngsets in self._rangesets: 511 | # rngsets is a _LinkedList of (RangeSet, value) tuples 512 | for rngset, value in rngsets: 513 | try: 514 | if rngset.intersection(rng): 515 | ret.append((self._values[value], rngset, value)) 516 | except TypeError: 517 | break 518 | # do NOT except ValueError - if `rng` is not rangelike, then error should be thrown. 519 | return ret 520 | 521 | def getoverlap(self, rng: Rangelike) -> List[V]: 522 | """ 523 | Returns a list of values corresponding to every distinct 524 | rangekey of this RangeDict that overlaps the given range. 525 | 526 | :param rng: Rangelike to search for 527 | :return: a list of values corresponding to each rangekey intersected by rng 528 | """ 529 | return [t[2] for t in self.getoverlapitems(rng)] 530 | 531 | def getoverlapranges(self, rng: Rangelike) -> List[RangeSet]: 532 | """ 533 | Returns a list of all rangekeys in this RangeDict that intersect with 534 | the given range. 535 | 536 | :param rng: Rangelike to search for 537 | :return: a list of all RangeSet rangekeys intersected by rng 538 | """ 539 | return [t[1] for t in self.getoverlapitems(rng)] 540 | 541 | def getoverlaprangesets(self, rng: Rangelike) -> List[List[RangeSet]]: 542 | """ 543 | Returns a list of RangeSets corresponding to the same value as every 544 | rangekey that intersects the given range. 545 | 546 | :param rng: Rangelike to search for 547 | :return: a list lists of rangesets that correspond to the same values as every rangekey intersected by rng 548 | """ 549 | return [t[0] for t in self.getoverlapitems(rng)] 550 | 551 | def getvalue(self, value: V) -> List[RangeSet]: 552 | """ 553 | Returns the list of RangeSets corresponding to the given value. 554 | 555 | Raises a `KeyError` if the given value is not corresponded to by 556 | any RangeSets in this RangeDict. 557 | 558 | :param value: value to search for 559 | :return: a list of rangekeys that correspond to the given value 560 | """ 561 | try: 562 | return self._values[value] 563 | except KeyError: 564 | raise KeyError(f"value '{value}' is not present in this RangeDict") 565 | 566 | def set(self, item: T, new_value: V) -> V: 567 | """ 568 | Changes the value corresponding to the given `item` to the given 569 | `new_value`, such that all ranges corresponding to the old value 570 | now correspond to the `new_value` instead. 571 | 572 | Returns the original, overwritten value. 573 | 574 | If the given item is not found, raises a `KeyError`. 575 | 576 | :param item: item to search for 577 | :param new_value: value to set for all rangekeys sharing the same value as item corresponds to 578 | :return: the previous value those rangekeys corresponded to 579 | """ 580 | try: 581 | old_value = self.get(item) 582 | except KeyError: 583 | raise KeyError(f"Item '{item}' is not in any Range in this RangeDict") 584 | self.setvalue(old_value, new_value) 585 | return old_value 586 | 587 | def setvalue(self, old_value: V, new_value: V) -> None: 588 | """ 589 | Changes all ranges corresponding to the given `old_value` to correspond 590 | to the given `new_value` instead. 591 | 592 | Raises a `KeyError` if the given `old_value` isn't found. 593 | 594 | :param old_value: value to change for all keys that correspond to it 595 | :param new_value: value to replace it with 596 | """ 597 | try: 598 | rangesets = list(self._values[old_value]) 599 | except KeyError: 600 | raise KeyError(f"Value '{old_value}' is not in this RangeDict") 601 | for rngset in rangesets: 602 | self.add(rngset, new_value) 603 | 604 | def popitem(self, item: T) -> Tuple[List[RangeSet], RangeSet, Range, V]: 605 | """ 606 | Returns the value corresponding to the given item, the Range containing 607 | it, and the set of other contiguous ranges that would have also yielded 608 | the same value, as a 4-tuple 609 | `([RangeSet1, Rangeset2, ...], RangeSet, Range, value)`. 610 | 611 | In reverse order, that is 612 | - the value corresponding to item 613 | - the single continuous range directly containing the item 614 | - the RangeSet directly containing the item and corresponding to the 615 | value 616 | - a list of all RangeSets (of various non-mutually-comparable types) 617 | that all correspond to the value. Most of the time, this will be a 618 | single-element list, if only one type of Range is used in the 619 | RangeDict. Otherwise, if ranges of multiple types (e.g. int ranges, 620 | string ranges) correspond to the same value, this list will contain 621 | all of them. 622 | 623 | Also removes all of the above from this RangeDict. 624 | 625 | While this method is used a lot internally, it's usually easier to 626 | simply use `.pop()`, `.poprange()`, `.poprangeset()`, or 627 | `.poprangesets()` to get the single item of interest. 628 | 629 | Raises a KeyError if the desired item is not found. 630 | 631 | :param item: item to search for 632 | :return: a 4-tuple (keys with same value, containing RangeSet, containing Range, value) 633 | """ 634 | # search for item linked list-style 635 | for rngsetlist in self._rangesets: 636 | # rngsetlist is a _LinkedList of (RangeSet, value) tuples 637 | cur = rngsetlist.first 638 | while cur: 639 | try: 640 | rng = cur.value[0].getrange(item) 641 | rngsetlist.pop_node(cur) 642 | rngsets = self._values.pop(cur.value[1]) 643 | self.popempty() 644 | return rngsets, cur.value[0], rng, cur.value[1] 645 | except IndexError: 646 | # try the next range correspondence 647 | cur = cur.next 648 | continue 649 | except TypeError: 650 | # try ranges of a different type 651 | break 652 | raise KeyError(f"'{item}' was not found in any range") 653 | 654 | def poprangesets(self, item: T) -> List[RangeSet]: 655 | """ 656 | Finds the value to which the given item corresponds, and returns the 657 | list of RangeSets that correspond to that value (see 658 | `.getrangesets()`). 659 | 660 | Also removes the value, and all RangeSets from this RangeDict. To 661 | remove just one range and leave the rest intact, use `.remove()` 662 | instead. 663 | 664 | Raises a `KeyError` if the given item is not found. 665 | 666 | :param item: item to search for 667 | :return: all RangeSets in this RangeDict that correspond to the same value as the given item 668 | """ 669 | return self.popitem(item)[0] 670 | 671 | def poprangeset(self, item: T) -> RangeSet: 672 | """ 673 | Finds the value to which the given item corresponds in this RangeDict, 674 | and then returns the RangeSet containing the given item that 675 | corresponds to that value. 676 | 677 | Also removes the value and all ranges that correspond to it from this 678 | RangeDict. To remove just one range and leave the rest intact, use 679 | `.remove()` instead. 680 | 681 | Raises a `KeyError` if the given item is not found. 682 | 683 | :param item: item to search for 684 | :return: the RangeSet key containing the given item 685 | """ 686 | return self.popitem(item)[1] 687 | 688 | def poprange(self, item: T) -> Range: 689 | """ 690 | Finds the value to which the given item corresponds in this RangeDict, 691 | and then returns the single contiguous range containing the given item 692 | that corresponds to that value. 693 | 694 | Also removes the value and all ranges that correspond to it from this 695 | RangeDict. To remove just one range and leave the rest intact, use 696 | `.remove()` instead. 697 | 698 | Raises a `KeyError` if the given item is not found. 699 | 700 | :param item: item to search for 701 | :return: the Range containing the given item 702 | """ 703 | return self.popitem(item)[2] 704 | 705 | def pop(self, item: T, default: Any = _sentinel) -> Union[V, Any]: 706 | """ 707 | Returns the value corresponding to the most recently-added range that 708 | contains the given item. Also removes the returned value and all 709 | ranges corresponding to it from this RangeDict. 710 | 711 | The argument `default` is optional, just like in python's built-in 712 | `dict.pop()`, if default is given, then if the item is not found, 713 | returns that instead. 714 | Otherwise, raises a `KeyError`. 715 | 716 | :param item: item to search for 717 | :param default: optionally, a value to return, if item is not found 718 | (if not provided, raises a KeyError if not found) 719 | :return: the value corrsponding to the item, or default if item is not found 720 | """ 721 | try: 722 | return self.popitem(item)[3] 723 | except KeyError: 724 | if default != RangeDict._sentinel: 725 | return default 726 | raise 727 | 728 | def popvalue(self, value: V) -> List[RangeSet]: 729 | """ 730 | Removes all ranges corresponding to the given value from this RangeDict, 731 | as well as the value itself. Returns a list of all the RangeSets of 732 | various types that corresponded to the given value. 733 | 734 | :param value: value to purge 735 | :return: all RangeSets in this RangeDict that correspond to the given value 736 | """ 737 | # find a RangeSet corresponding to the value, which we can use as a key 738 | sample_item = self._values[value][0] 739 | # use that RangeSet to do the regular pop() function 740 | return self.popitem(sample_item)[0] 741 | 742 | def popempty(self) -> None: 743 | """ 744 | Removes all empty ranges from this RangeDict, as well as all values 745 | that have no corresponding ranges. The RangeDict calls this method on 746 | itself after most operations that modify it, so calling it manually, 747 | while possible, will usually do nothing. 748 | """ 749 | # We start by traversing _ranges and removing all empty things. 750 | rngsetlistnode = self._rangesets.first 751 | while rngsetlistnode: 752 | # rngsetlistnode is a Node(_LinkedList((RangeSet, value))) 753 | rngsetnode = rngsetlistnode.value.first 754 | # First, empty all RangeSets 755 | while rngsetnode: 756 | # rngsetnode is a Node((RangeSet, value)) 757 | rngset = rngsetnode.value[0] 758 | # popempty() on the RangeSet in rngsetnode 759 | rngset.popempty() 760 | # if the RangeSet is empty, then remove it. 761 | if rngset.isempty(): 762 | rngsetlistnode.value.pop_node(rngsetnode) 763 | # also remove this RangeSet from .values() 764 | self._values[rngsetnode.value[1]].remove(rngset) 765 | # deletion while traversing is fine in a linked list only 766 | rngsetnode = rngsetnode.next 767 | # Next, check for an empty list of RangeSets 768 | if len(rngsetlistnode.value) == 0: 769 | self._rangesets.pop_node(rngsetlistnode) 770 | # in this case, there are no RangeSets to pop, so we can leave ._values alone 771 | # and finally, advance to the next list of RangeSets 772 | rngsetlistnode = rngsetlistnode.next 773 | # Once we've removed all RangeSets, we then remove all values with no corresponding Range-like objects 774 | for value in list(self._values.keys()): 775 | if not self._values[value]: 776 | self._values.pop(value) 777 | 778 | def remove(self, rng: Rangelike): 779 | """ 780 | Removes the given Range or RangeSet from this RangeDict, leaving behind 781 | 'empty space'. 782 | 783 | Afterwards, empty ranges, and values with no remaining corresponding 784 | ranges, will be automatically removed. 785 | 786 | :param rng: Range to remove as rangekeys from this dict 787 | """ 788 | # no mutation unless the operation is successful 789 | rng = RangeSet(rng) 790 | temp = self.copy() 791 | # do the removal on the copy 792 | for rngsetlist in temp._rangesets: 793 | for rngset, value in rngsetlist: 794 | try: 795 | rngset.discard(rng) 796 | except TypeError: 797 | break 798 | temp.popempty() 799 | self._rangesets, self._values = temp._rangesets, temp._values 800 | 801 | def isempty(self) -> bool: 802 | """ 803 | :return: `True` if this RangeDict contains no values, and `False` otherwise. 804 | """ 805 | return not self._values 806 | 807 | def ranges(self) -> List[RangeSet]: 808 | """ 809 | Returns a list of RangeSets that correspond to some value in this 810 | RangeDict, ordered as follows: 811 | 812 | All Rangesets of comparable types are grouped together, with 813 | order corresponding to the order in which the first RangeSet of 814 | the given type was added to this RangeDict (earliest first). 815 | Within each such group, RangeSets are ordered in increasing order 816 | of their lower bounds. 817 | 818 | This function is analagous to Python's built-in `dict.keys()` 819 | 820 | :return: a list of RangeSet keys in this RangeDict 821 | """ 822 | return [rngset for rngsetlist in self._rangesets for rngset, value in rngsetlist] 823 | 824 | def values(self) -> List[V]: 825 | """ 826 | Returns a list of values that are corresponded to by some RangeSet in 827 | this RangeDict, ordered by how recently they were added (via .`add()` 828 | or `.update()`) or set (via `.set()` or `.setvalue()`), with the 829 | oldest values being listed first. 830 | 831 | This function is synonymous to Python's built-in `dict.values()` 832 | 833 | :return: a list of values contained in this RangeDict 834 | """ 835 | return list(self._values.keys()) 836 | 837 | def items(self) -> List[Tuple[Any, Any]]: 838 | """ 839 | :return: a list of 2-tuples `(list of ranges corresponding to value, value)`, ordered 840 | by time-of-insertion of the values (see `.values()` for more detail) 841 | """ 842 | return [(rngsets, value) for value, rngsets in self._values.items()] 843 | 844 | def clear(self) -> None: 845 | """ 846 | Removes all items from this RangeDict, including all of the Ranges 847 | that serve as keys, and the values to which they correspond. 848 | """ 849 | self._rangesets = _LinkedList() 850 | self._values = {} 851 | 852 | def copy(self) -> 'RangeDict': 853 | """ 854 | :return: a shallow copy of this RangeDict 855 | """ 856 | return RangeDict(self) 857 | 858 | def _sort_ranges(self) -> None: 859 | """ Helper method to gnomesort all _LinkedLists-of-RangeSets. """ 860 | for linkedlist in self._rangesets: 861 | linkedlist.gnomesort() 862 | 863 | def _coalesce_infinite_ranges(self) -> None: 864 | """ 865 | Helper method, intended for internal use only 866 | If any element of _rangesets ends up equal to Range(-inf, inf) due to some weird 867 | addition operation, then sorts that infinite range to the end of _rangesets. 868 | """ 869 | def __condition(rngsets): 870 | return len(rngsets) == 1 and all(rngset.containseverything() for rngset, _ in rngsets) 871 | if any(__condition(r) for r in self._rangesets): 872 | self._rangesets = _LinkedList( 873 | [r for r in self._rangesets if not __condition(r)] + [r for r in self._rangesets if __condition(r)] 874 | ) 875 | 876 | def __setitem__(self, key: Rangelike, value: V): 877 | """ 878 | Equivalent to :func:`~RangeDict.add`. 879 | """ 880 | self.add(key, value) 881 | 882 | def __getitem__(self, item: T): 883 | """ 884 | Equivalent to :func:`~RangeDict.get`. If `item` is a range, then this will only 885 | return a corresponding value if `item` is completely contained by one 886 | of this RangeDict's rangekeys. To get values corresponding to all 887 | overlapping ranges, use `.getoverlap(item)` instead. 888 | """ 889 | return self.get(item) 890 | 891 | def __contains__(self, item: T): 892 | """ 893 | :return: True if the given item corresponds to any single value in this RangeDict, False otherwise 894 | """ 895 | sentinel2 = object() 896 | return not (self.get(item, sentinel2) is sentinel2) 897 | # return any(item in rngset for rngsetlist in self._rangesets for (rngset, value) in rngsetlist) 898 | 899 | def __len__(self) -> int: 900 | """ 901 | Returns the number of values, not the number of unique Ranges, 902 | since determining how to count Ranges is Hard 903 | 904 | :return: the number of unique values contained in this RangeDict 905 | """ 906 | return len(self._values) 907 | 908 | def __eq__(self, other: 'RangeDict') -> bool: 909 | """ 910 | Tests whether this RangeDict is equal to the given RangeDict (has the same keys and values). 911 | Note that this always tests equality for values, not identity, regardless of whether this 912 | RangeDict was constructed in 'strict' mode. 913 | 914 | :param other: RangeDict to compare against 915 | :return: True if this RangeDict is equal to the given RangeDict, False otherwise 916 | """ 917 | # Actually comparing two LinkedLists together is hard, and all relevant information should be in _values anyway 918 | # Ordering is the big challenge here - you can't order the nested LinkedLists. 919 | # But what's important for equality between RangeDicts is that they have the same key-value pairs, which is 920 | # properly checked just by comparing _values 921 | return isinstance(other, RangeDict) and self._values == other._values # and self._rangesets == other._rangesets 922 | 923 | def __ne__(self, other: 'RangeDict') -> bool: 924 | """ 925 | :param other: RangeDict to compare against 926 | :return: False if this RangeDict is equal to the given RangeDict, True otherwise 927 | """ 928 | return not self.__eq__(other) 929 | 930 | def __bool__(self) -> bool: 931 | """ 932 | :return: False if this RangeDict is empty, True otherwise 933 | """ 934 | return not self.isempty() 935 | 936 | def __str__(self): 937 | # nested f-strings, whee 938 | return f"""{{{ 939 | ', '.join( 940 | f"{{{', '.join(str(rng) for rngset in rngsets for rng in rngset)}}}: {value}" 941 | for value, rngsets in self._values.items() 942 | ) 943 | }}}""" 944 | 945 | def __repr__(self): 946 | return f"""RangeDict{{{ 947 | ', '.join( 948 | f"RangeSet{{{', '.join(repr(rng) for rngset in rngsets for rng in rngset)}}}: {repr(value)}" 949 | for value, rngsets in self._values.items() 950 | ) 951 | }}}""" 952 | --------------------------------------------------------------------------------