├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENCE ├── Makefile ├── README.md ├── fractional_indexing.py ├── poetry.lock ├── pyproject.toml ├── setup.py └── tests.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - uses: actions/setup-python@v2 9 | - run: make install 10 | - run: make test 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | 142 | .idea/ 143 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | poetry --version || python3 -m pip install poetry 3 | poetry install 4 | 5 | test: 6 | poetry run pytest tests.py --verbose 7 | 8 | clean: 9 | rm -rf build dist 10 | release: clean 11 | poetry run python setup.py sdist bdist_wheel 12 | poetry run twine upload --repository=fractional-indexing dist/* 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fractional Indexing 2 | 3 | This is based on [Implementing Fractional Indexing 4 | ](https://observablehq.com/@dgreensp/implementing-fractional-indexing) by [David Greenspan 5 | ](https://github.com/dgreensp). 6 | 7 | Fractional indexing is a technique to create an ordering that can be used 8 | for [Realtime Editing of Ordered Sequences](https://www.figma.com/blog/realtime-editing-of-ordered-sequences/). 9 | 10 | This implementation includes variable-length integers, and the prepend/append optimization described in David's article. 11 | 12 | ## Installation 13 | 14 | ```bash 15 | $ pip install fractional-indexing 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### Generate a single key 21 | 22 | ```python 23 | from fractional_indexing import generate_key_between 24 | 25 | 26 | # Insert at the beginning 27 | first = generate_key_between(None, None) 28 | assert first == 'a0' 29 | 30 | # Insert after 1st 31 | second = generate_key_between(first, None) 32 | assert second == 'a1' 33 | 34 | # Insert after 2nd 35 | third = generate_key_between(second, None) 36 | assert third == 'a2' 37 | 38 | # Insert before 1st 39 | zeroth = generate_key_between(None, first) 40 | assert zeroth == 'Zz' 41 | 42 | # Insert in between 2nd and 3rd (midpoint) 43 | second_and_half = generate_key_between(second, third) 44 | assert second_and_half == 'a1V' 45 | 46 | ``` 47 | 48 | ### Generate multiple keys 49 | 50 | Use this when generating multiple keys at some known position, as it spaces out indexes more evenly and leads to shorter keys. 51 | 52 | ```python 53 | from fractional_indexing import generate_n_keys_between 54 | 55 | 56 | # Insert 3 at the beginning 57 | keys = generate_n_keys_between(None, None, n=3) 58 | assert keys == ['a0', 'a1', 'a2'] 59 | 60 | # Insert 3 after 1st 61 | keys = generate_n_keys_between('a0', None, n=3) 62 | assert keys == ['a1', 'a2', 'a3'] 63 | 64 | # Insert 3 before 1st 65 | keys = generate_n_keys_between(None, 'a0', n=3) 66 | assert keys == ['Zx', 'Zy', 'Zz'] 67 | 68 | # Insert 3 in between 2nd and 3rd (midpoint) 69 | keys = generate_n_keys_between('a1', 'a2', n=3) 70 | assert keys == ['a1G', 'a1V', 'a1l'] 71 | 72 | ``` 73 | 74 | ### Validate a key 75 | 76 | ```python 77 | from fractional_indexing import validate_order_key, FIError 78 | 79 | 80 | validate_order_key('a0') 81 | 82 | try: 83 | validate_order_key('foo') 84 | except FIError as e: 85 | print(e) # fractional_indexing.FIError: invalid order key: foo 86 | 87 | ``` 88 | 89 | ### Use custom base digits 90 | 91 | By default, this library uses Base62 character encoding. To use a different set of digits, pass them in as the `digits` 92 | argument to `generate_key_between()`, `generate_n_keys_between()`, and `validate_order_key()`: 93 | 94 | ```python 95 | from fractional_indexing import generate_key_between, generate_n_keys_between, validate_order_key 96 | 97 | 98 | BASE_95_DIGITS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' 99 | 100 | assert generate_key_between(None, None, digits=BASE_95_DIGITS) == 'a ' 101 | assert generate_key_between('a ', None, digits=BASE_95_DIGITS) == 'a!' 102 | assert generate_key_between(None, 'a ', digits=BASE_95_DIGITS) == 'Z~' 103 | 104 | assert generate_n_keys_between('a ', 'a!', n=3, digits=BASE_95_DIGITS) == ['a"', 'a#', 'a$'] 105 | 106 | validate_order_key('a ', digits=BASE_95_DIGITS) 107 | 108 | ``` 109 | 110 | ## Other Languages 111 | 112 | This is a Python port of the original JavaScript implementation by [@rocicorp](https://github.com/rocicorp). That means 113 | that this implementation is byte-for-byte compatible with: 114 | 115 | | Language | Repo | 116 | |------------|-------------------------------------------------------| 117 | | JavaScript | https://github.com/rocicorp/fractional-indexing | 118 | | Go | https://github.com/rocicorp/fracdex | 119 | | Kotlin | https://github.com/darvelo/fractional-indexing-kotlin | 120 | | Ruby | https://github.com/kazu-2020/fractional_indexer | 121 | -------------------------------------------------------------------------------- /fractional_indexing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides functions for generating ordering strings 3 | 4 | . 5 | 6 | 7 | 8 | 9 | """ 10 | from math import floor 11 | from typing import Optional, List 12 | import decimal 13 | 14 | 15 | __version__ = '0.1.3' 16 | __licence__ = 'CC0 1.0 Universal' 17 | 18 | BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 19 | 20 | 21 | class FIError(Exception): 22 | pass 23 | 24 | 25 | def midpoint(a: str, b: Optional[str], digits: str) -> str: 26 | """ 27 | `a` may be empty string, `b` is null or non-empty string. 28 | `a < b` lexicographically if `b` is non-null. 29 | no trailing zeros allowed. 30 | digits is a string such as '0123456789' for base 10. Digits must be in 31 | ascending character code order! 32 | 33 | """ 34 | zero = digits[0] 35 | if b is not None and a >= b: 36 | raise FIError(f'{a} >= {b}') 37 | if (a and a[-1]) == zero or (b is not None and b[-1] == zero): 38 | raise FIError('trailing zero') 39 | if b: 40 | # remove longest common prefix. pad `a` with 0s as we 41 | # go. note that we don't need to pad `b`, because it can't 42 | # end before `a` while traversing the common prefix. 43 | n = 0 44 | for x, y in zip(a.ljust(len(b), zero), b): 45 | if x == y: 46 | n += 1 47 | continue 48 | break 49 | 50 | if n > 0: 51 | return b[:n] + midpoint(a[n:], b[n:], digits) 52 | 53 | # first digits (or lack of digit) are different 54 | try: 55 | digit_a = digits.index(a[0]) if a else 0 56 | except IndexError: 57 | digit_a = -1 58 | try: 59 | digit_b = digits.index(b[0]) if b is not None else len(digits) 60 | except IndexError: 61 | digit_b = -1 62 | 63 | if digit_b - digit_a > 1: 64 | min_digit = round_half_up(0.5 * (digit_a + digit_b)) 65 | return digits[min_digit] 66 | else: 67 | if b is not None and len(b) > 1: 68 | return b[:1] 69 | else: 70 | # `b` is null or has length 1 (a single digit). 71 | # the first digit of `a` is the previous digit to `b`, 72 | # or 9 if `b` is null. 73 | # given, for example, midpoint('49', '5'), return 74 | # '4' + midpoint('9', null), which will become 75 | # '4' + '9' + midpoint('', null), which is '495' 76 | return digits[digit_a] + midpoint(a[1:], None, digits) 77 | 78 | 79 | def validate_integer(i: str): 80 | if len(i) != get_integer_length(i[0]): 81 | raise FIError(f'invalid integer part of order key: {i}') 82 | 83 | 84 | def get_integer_length(head): 85 | if 'a' <= head <= 'z': 86 | return ord(head) - ord('a') + 2 87 | elif 'A' <= head <= 'Z': 88 | return ord('Z') - ord(head[0]) + 2 89 | raise FIError('invalid order key head: ' + head) 90 | 91 | 92 | def get_integer_part(key: str) -> str: 93 | integer_part_length = get_integer_length(key[0]) 94 | if integer_part_length > len(key): 95 | raise FIError(f'invalid order key: {key}') 96 | return key[:integer_part_length] 97 | 98 | 99 | def validate_order_key(key: str, digits=BASE_62_DIGITS): 100 | zero = digits[0] 101 | smallest = 'A' + (zero * 26) 102 | if key == smallest: 103 | raise FIError(f'invalid order key: {key}') 104 | 105 | # get_integer_part() will throw if the first character is bad, 106 | # or the key is too short. we'd call it to check these things 107 | # even if we didn't need the result 108 | i = get_integer_part(key) 109 | f = key[len(i):] 110 | if f and f[-1] == zero: 111 | raise FIError(f'invalid order key: {key}') 112 | 113 | 114 | def increment_integer(x: str, digits: str) -> Optional[str]: 115 | zero = digits[0] 116 | validate_integer(x) 117 | head, *digs = x 118 | carry = True 119 | for i in reversed(range(len(digs))): 120 | d = digits.index(digs[i]) + 1 121 | if d == len(digits): 122 | digs[i] = zero 123 | else: 124 | digs[i] = digits[d] 125 | carry = False 126 | break 127 | if carry: 128 | if head == 'Z': 129 | return 'a' + zero 130 | elif head == 'z': 131 | return None 132 | h = chr(ord(head[0]) + 1) 133 | if h > 'a': 134 | digs.append(zero) 135 | else: 136 | digs.pop() 137 | return h + ''.join(digs) 138 | else: 139 | return head + ''.join(digs) 140 | 141 | 142 | def decrement_integer(x, digits): 143 | validate_integer(x) 144 | head, *digs = x 145 | borrow = True 146 | for i in reversed(range(len(digs))): 147 | 148 | try: 149 | index = digits.index(digs[i]) 150 | except IndexError: 151 | index = -1 152 | d = index - 1 153 | 154 | if d == -1: 155 | digs[i] = digits[-1] 156 | else: 157 | digs[i] = digits[d] 158 | borrow = False 159 | break 160 | if borrow: 161 | if head == 'a': 162 | return 'Z' + digits[-1] 163 | if head == 'A': 164 | return None 165 | h = chr(ord(head[0]) - 1) 166 | if h < 'Z': 167 | digs.append(digits[-1]) 168 | else: 169 | digs.pop() 170 | return h + ''.join(digs) 171 | else: 172 | return head + ''.join(digs) 173 | 174 | 175 | def generate_key_between(a: Optional[str], b: Optional[str], digits=BASE_62_DIGITS) -> str: 176 | """ 177 | `a` is an order key or null (START). 178 | `b` is an order key or null (END). 179 | `a < b` lexicographically if both are non-null. 180 | digits is a string such as '0123456789' for base 10. Digits must be in 181 | ascending character code order! 182 | 183 | """ 184 | zero = digits[0] 185 | if a is not None: 186 | validate_order_key(a, digits=digits) 187 | if b is not None: 188 | validate_order_key(b, digits=digits) 189 | if a is not None and b is not None and a >= b: 190 | raise FIError(f'{a} >= {b}') 191 | 192 | if a is None: 193 | if b is None: 194 | return 'a' + zero 195 | ib = get_integer_part(b) 196 | fb = b[len(ib):] 197 | if ib == 'A' + (zero * 26): 198 | return ib + midpoint('', fb, digits) 199 | if ib < b: 200 | return ib 201 | res = decrement_integer(ib, digits) 202 | if res is None: 203 | raise FIError('cannot decrement any more') 204 | return res 205 | 206 | if b is None: 207 | ia = get_integer_part(a) 208 | fa = a[len(ia):] 209 | i = increment_integer(ia, digits) 210 | return ia + midpoint(fa, None, digits) if i is None else i 211 | 212 | ia = get_integer_part(a) 213 | fa = a[len(ia):] 214 | ib = get_integer_part(b) 215 | fb = b[len(ib):] 216 | if ia == ib: 217 | return ia + midpoint(fa, fb, digits) 218 | i = increment_integer(ia, digits) 219 | if i is None: 220 | raise FIError('cannot increment any more') 221 | 222 | if i < b: 223 | return i 224 | 225 | return ia + midpoint(fa, None, digits) 226 | 227 | 228 | def generate_n_keys_between(a: Optional[str], b: Optional[str], n: int, digits=BASE_62_DIGITS) -> List[str]: 229 | """ 230 | same preconditions as generate_keys_between(). 231 | n >= 0. 232 | Returns an array of n distinct keys in sorted order. 233 | If a and b are both null, returns [a0, a1, ...] 234 | If one or the other is null, returns consecutive "integer" 235 | keys. Otherwise, returns relatively short keys between 236 | 237 | """ 238 | if n == 0: 239 | return [] 240 | if n == 1: 241 | return [generate_key_between(a, b, digits)] 242 | if b is None: 243 | c = generate_key_between(a, b, digits) 244 | result = [c] 245 | for i in range(n - 1): 246 | c = generate_key_between(c, b, digits) 247 | result.append(c) 248 | return result 249 | 250 | if a is None: 251 | c = generate_key_between(a, b, digits) 252 | result = [c] 253 | for i in range(n - 1): 254 | c = generate_key_between(a, c, digits) 255 | result.append(c) 256 | return list(reversed(result)) 257 | 258 | mid = floor(n / 2) 259 | c = generate_key_between(a, b, digits) 260 | return [ 261 | *generate_n_keys_between(a, c, mid, digits), 262 | c, 263 | *generate_n_keys_between(c, b, n - mid - 1, digits) 264 | ] 265 | 266 | 267 | def round_half_up(n: float) -> int: 268 | """ 269 | >>> round_half_up(0.4) 270 | 0 271 | >>> round_half_up(0.8) 272 | 1 273 | >>> round_half_up(0.5) 274 | 1 275 | >>> round_half_up(1.5) 276 | 2 277 | >>> round_half_up(2.5) 278 | 3 279 | """ 280 | return int( 281 | decimal.Decimal(str(n)).quantize( 282 | decimal.Decimal('1'), 283 | rounding=decimal.ROUND_HALF_UP 284 | ) 285 | ) 286 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "bleach" 25 | version = "4.1.0" 26 | description = "An easy safelist-based HTML-sanitizing tool." 27 | category = "main" 28 | optional = false 29 | python-versions = ">=3.6" 30 | 31 | [package.dependencies] 32 | packaging = "*" 33 | six = ">=1.9.0" 34 | webencodings = "*" 35 | 36 | [[package]] 37 | name = "certifi" 38 | version = "2021.10.8" 39 | description = "Python package for providing Mozilla's CA Bundle." 40 | category = "main" 41 | optional = false 42 | python-versions = "*" 43 | 44 | [[package]] 45 | name = "cffi" 46 | version = "1.15.0" 47 | description = "Foreign Function Interface for Python calling C code." 48 | category = "main" 49 | optional = false 50 | python-versions = "*" 51 | 52 | [package.dependencies] 53 | pycparser = "*" 54 | 55 | [[package]] 56 | name = "charset-normalizer" 57 | version = "2.0.12" 58 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 59 | category = "main" 60 | optional = false 61 | python-versions = ">=3.5.0" 62 | 63 | [package.extras] 64 | unicode_backport = ["unicodedata2"] 65 | 66 | [[package]] 67 | name = "colorama" 68 | version = "0.4.4" 69 | description = "Cross-platform colored terminal text." 70 | category = "main" 71 | optional = false 72 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 73 | 74 | [[package]] 75 | name = "coverage" 76 | version = "6.3.2" 77 | description = "Code coverage measurement for Python" 78 | category = "main" 79 | optional = false 80 | python-versions = ">=3.7" 81 | 82 | [package.extras] 83 | toml = ["tomli"] 84 | 85 | [[package]] 86 | name = "cryptography" 87 | version = "36.0.1" 88 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 89 | category = "main" 90 | optional = false 91 | python-versions = ">=3.6" 92 | 93 | [package.dependencies] 94 | cffi = ">=1.12" 95 | 96 | [package.extras] 97 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] 98 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 99 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] 100 | sdist = ["setuptools_rust (>=0.11.4)"] 101 | ssh = ["bcrypt (>=3.1.5)"] 102 | test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] 103 | 104 | [[package]] 105 | name = "docutils" 106 | version = "0.18.1" 107 | description = "Docutils -- Python Documentation Utilities" 108 | category = "main" 109 | optional = false 110 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 111 | 112 | [[package]] 113 | name = "idna" 114 | version = "3.3" 115 | description = "Internationalized Domain Names in Applications (IDNA)" 116 | category = "main" 117 | optional = false 118 | python-versions = ">=3.5" 119 | 120 | [[package]] 121 | name = "importlib-metadata" 122 | version = "4.11.1" 123 | description = "Read metadata from Python packages" 124 | category = "main" 125 | optional = false 126 | python-versions = ">=3.7" 127 | 128 | [package.dependencies] 129 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 130 | zipp = ">=0.5" 131 | 132 | [package.extras] 133 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 134 | perf = ["ipython"] 135 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 136 | 137 | [[package]] 138 | name = "iniconfig" 139 | version = "1.1.1" 140 | description = "iniconfig: brain-dead simple config-ini parsing" 141 | category = "dev" 142 | optional = false 143 | python-versions = "*" 144 | 145 | [[package]] 146 | name = "jeepney" 147 | version = "0.7.1" 148 | description = "Low-level, pure Python DBus protocol wrapper." 149 | category = "main" 150 | optional = false 151 | python-versions = ">=3.6" 152 | 153 | [package.extras] 154 | test = ["pytest", "pytest-trio", "pytest-asyncio", "testpath", "trio", "async-timeout"] 155 | trio = ["trio", "async-generator"] 156 | 157 | [[package]] 158 | name = "keyring" 159 | version = "23.5.0" 160 | description = "Store and access your passwords safely." 161 | category = "main" 162 | optional = false 163 | python-versions = ">=3.7" 164 | 165 | [package.dependencies] 166 | importlib-metadata = ">=3.6" 167 | jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} 168 | pywin32-ctypes = {version = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1", markers = "sys_platform == \"win32\""} 169 | SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} 170 | 171 | [package.extras] 172 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 173 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] 174 | 175 | [[package]] 176 | name = "packaging" 177 | version = "21.3" 178 | description = "Core utilities for Python packages" 179 | category = "main" 180 | optional = false 181 | python-versions = ">=3.6" 182 | 183 | [package.dependencies] 184 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 185 | 186 | [[package]] 187 | name = "pkginfo" 188 | version = "1.8.2" 189 | description = "Query metadatdata from sdists / bdists / installed packages." 190 | category = "main" 191 | optional = false 192 | python-versions = "*" 193 | 194 | [package.extras] 195 | testing = ["coverage", "nose"] 196 | 197 | [[package]] 198 | name = "pluggy" 199 | version = "1.0.0" 200 | description = "plugin and hook calling mechanisms for python" 201 | category = "dev" 202 | optional = false 203 | python-versions = ">=3.6" 204 | 205 | [package.dependencies] 206 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 207 | 208 | [package.extras] 209 | dev = ["pre-commit", "tox"] 210 | testing = ["pytest", "pytest-benchmark"] 211 | 212 | [[package]] 213 | name = "py" 214 | version = "1.11.0" 215 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 216 | category = "dev" 217 | optional = false 218 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 219 | 220 | [[package]] 221 | name = "pycparser" 222 | version = "2.21" 223 | description = "C parser in Python" 224 | category = "main" 225 | optional = false 226 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 227 | 228 | [[package]] 229 | name = "pygments" 230 | version = "2.11.2" 231 | description = "Pygments is a syntax highlighting package written in Python." 232 | category = "main" 233 | optional = false 234 | python-versions = ">=3.5" 235 | 236 | [[package]] 237 | name = "pyparsing" 238 | version = "3.0.7" 239 | description = "Python parsing module" 240 | category = "main" 241 | optional = false 242 | python-versions = ">=3.6" 243 | 244 | [package.extras] 245 | diagrams = ["jinja2", "railroad-diagrams"] 246 | 247 | [[package]] 248 | name = "pytest" 249 | version = "7.0.1" 250 | description = "pytest: simple powerful testing with Python" 251 | category = "dev" 252 | optional = false 253 | python-versions = ">=3.6" 254 | 255 | [package.dependencies] 256 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 257 | attrs = ">=19.2.0" 258 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 259 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 260 | iniconfig = "*" 261 | packaging = "*" 262 | pluggy = ">=0.12,<2.0" 263 | py = ">=1.8.2" 264 | tomli = ">=1.0.0" 265 | 266 | [package.extras] 267 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 268 | 269 | [[package]] 270 | name = "pywin32-ctypes" 271 | version = "0.2.0" 272 | description = "" 273 | category = "main" 274 | optional = false 275 | python-versions = "*" 276 | 277 | [[package]] 278 | name = "readme-renderer" 279 | version = "32.0" 280 | description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse" 281 | category = "main" 282 | optional = false 283 | python-versions = ">=3.6" 284 | 285 | [package.dependencies] 286 | bleach = ">=2.1.0" 287 | docutils = ">=0.13.1" 288 | Pygments = ">=2.5.1" 289 | 290 | [package.extras] 291 | md = ["cmarkgfm (>=0.5.0,<0.7.0)"] 292 | 293 | [[package]] 294 | name = "requests" 295 | version = "2.27.1" 296 | description = "Python HTTP for Humans." 297 | category = "main" 298 | optional = false 299 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 300 | 301 | [package.dependencies] 302 | certifi = ">=2017.4.17" 303 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 304 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 305 | urllib3 = ">=1.21.1,<1.27" 306 | 307 | [package.extras] 308 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 309 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 310 | 311 | [[package]] 312 | name = "requests-toolbelt" 313 | version = "0.9.1" 314 | description = "A utility belt for advanced users of python-requests" 315 | category = "main" 316 | optional = false 317 | python-versions = "*" 318 | 319 | [package.dependencies] 320 | requests = ">=2.0.1,<3.0.0" 321 | 322 | [[package]] 323 | name = "rfc3986" 324 | version = "2.0.0" 325 | description = "Validating URI References per RFC 3986" 326 | category = "main" 327 | optional = false 328 | python-versions = ">=3.7" 329 | 330 | [package.extras] 331 | idna2008 = ["idna"] 332 | 333 | [[package]] 334 | name = "secretstorage" 335 | version = "3.3.1" 336 | description = "Python bindings to FreeDesktop.org Secret Service API" 337 | category = "main" 338 | optional = false 339 | python-versions = ">=3.6" 340 | 341 | [package.dependencies] 342 | cryptography = ">=2.0" 343 | jeepney = ">=0.6" 344 | 345 | [[package]] 346 | name = "six" 347 | version = "1.16.0" 348 | description = "Python 2 and 3 compatibility utilities" 349 | category = "main" 350 | optional = false 351 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 352 | 353 | [[package]] 354 | name = "tomli" 355 | version = "2.0.1" 356 | description = "A lil' TOML parser" 357 | category = "dev" 358 | optional = false 359 | python-versions = ">=3.7" 360 | 361 | [[package]] 362 | name = "tqdm" 363 | version = "4.62.3" 364 | description = "Fast, Extensible Progress Meter" 365 | category = "main" 366 | optional = false 367 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 368 | 369 | [package.dependencies] 370 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 371 | 372 | [package.extras] 373 | dev = ["py-make (>=0.1.0)", "twine", "wheel"] 374 | notebook = ["ipywidgets (>=6)"] 375 | telegram = ["requests"] 376 | 377 | [[package]] 378 | name = "twine" 379 | version = "3.8.0" 380 | description = "Collection of utilities for publishing packages on PyPI" 381 | category = "main" 382 | optional = false 383 | python-versions = ">=3.6" 384 | 385 | [package.dependencies] 386 | colorama = ">=0.4.3" 387 | importlib-metadata = ">=3.6" 388 | keyring = ">=15.1" 389 | pkginfo = ">=1.8.1" 390 | readme-renderer = ">=21.0" 391 | requests = ">=2.20" 392 | requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" 393 | rfc3986 = ">=1.4.0" 394 | tqdm = ">=4.14" 395 | urllib3 = ">=1.26.0" 396 | 397 | [[package]] 398 | name = "typing-extensions" 399 | version = "4.1.1" 400 | description = "Backported and Experimental Type Hints for Python 3.6+" 401 | category = "main" 402 | optional = false 403 | python-versions = ">=3.6" 404 | 405 | [[package]] 406 | name = "urllib3" 407 | version = "1.26.8" 408 | description = "HTTP library with thread-safe connection pooling, file post, and more." 409 | category = "main" 410 | optional = false 411 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 412 | 413 | [package.extras] 414 | brotli = ["brotlipy (>=0.6.0)"] 415 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 416 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 417 | 418 | [[package]] 419 | name = "webencodings" 420 | version = "0.5.1" 421 | description = "Character encoding aliases for legacy web content" 422 | category = "main" 423 | optional = false 424 | python-versions = "*" 425 | 426 | [[package]] 427 | name = "zipp" 428 | version = "3.7.0" 429 | description = "Backport of pathlib-compatible object wrapper for zip files" 430 | category = "main" 431 | optional = false 432 | python-versions = ">=3.7" 433 | 434 | [package.extras] 435 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 436 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 437 | 438 | [metadata] 439 | lock-version = "1.1" 440 | python-versions = ">=3.7, <4" 441 | content-hash = "a4dee8604843c054974a4f0903cf62cbfeb5ec30e0f5480bd0466fbcd0cf56e4" 442 | 443 | [metadata.files] 444 | atomicwrites = [ 445 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 446 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 447 | ] 448 | attrs = [ 449 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 450 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 451 | ] 452 | bleach = [ 453 | {file = "bleach-4.1.0-py2.py3-none-any.whl", hash = "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994"}, 454 | {file = "bleach-4.1.0.tar.gz", hash = "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da"}, 455 | ] 456 | certifi = [ 457 | {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, 458 | {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, 459 | ] 460 | cffi = [ 461 | {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, 462 | {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, 463 | {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, 464 | {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, 465 | {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, 466 | {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, 467 | {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, 468 | {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, 469 | {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, 470 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, 471 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, 472 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, 473 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, 474 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, 475 | {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, 476 | {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, 477 | {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, 478 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, 479 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, 480 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, 481 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, 482 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, 483 | {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, 484 | {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, 485 | {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, 486 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, 487 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, 488 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, 489 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, 490 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, 491 | {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, 492 | {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, 493 | {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, 494 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, 495 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, 496 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, 497 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, 498 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, 499 | {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, 500 | {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, 501 | {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, 502 | {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, 503 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, 504 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, 505 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, 506 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, 507 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, 508 | {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, 509 | {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, 510 | {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, 511 | ] 512 | charset-normalizer = [ 513 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, 514 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, 515 | ] 516 | colorama = [ 517 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 518 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 519 | ] 520 | coverage = [ 521 | {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, 522 | {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, 523 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, 524 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, 525 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, 526 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, 527 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, 528 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, 529 | {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, 530 | {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, 531 | {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, 532 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, 533 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, 534 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, 535 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, 536 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, 537 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, 538 | {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, 539 | {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, 540 | {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, 541 | {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, 542 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, 543 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, 544 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, 545 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, 546 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, 547 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, 548 | {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, 549 | {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, 550 | {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, 551 | {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, 552 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, 553 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, 554 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, 555 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, 556 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, 557 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, 558 | {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, 559 | {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, 560 | {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, 561 | {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, 562 | ] 563 | cryptography = [ 564 | {file = "cryptography-36.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:73bc2d3f2444bcfeac67dd130ff2ea598ea5f20b40e36d19821b4df8c9c5037b"}, 565 | {file = "cryptography-36.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:2d87cdcb378d3cfed944dac30596da1968f88fb96d7fc34fdae30a99054b2e31"}, 566 | {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74d6c7e80609c0f4c2434b97b80c7f8fdfaa072ca4baab7e239a15d6d70ed73a"}, 567 | {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:6c0c021f35b421ebf5976abf2daacc47e235f8b6082d3396a2fe3ccd537ab173"}, 568 | {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59a9d55027a8b88fd9fd2826c4392bd487d74bf628bb9d39beecc62a644c12"}, 569 | {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a817b961b46894c5ca8a66b599c745b9a3d9f822725221f0e0fe49dc043a3a3"}, 570 | {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:94ae132f0e40fe48f310bba63f477f14a43116f05ddb69d6fa31e93f05848ae2"}, 571 | {file = "cryptography-36.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7be0eec337359c155df191d6ae00a5e8bbb63933883f4f5dffc439dac5348c3f"}, 572 | {file = "cryptography-36.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e0344c14c9cb89e76eb6a060e67980c9e35b3f36691e15e1b7a9e58a0a6c6dc3"}, 573 | {file = "cryptography-36.0.1-cp36-abi3-win32.whl", hash = "sha256:4caa4b893d8fad33cf1964d3e51842cd78ba87401ab1d2e44556826df849a8ca"}, 574 | {file = "cryptography-36.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:391432971a66cfaf94b21c24ab465a4cc3e8bf4a939c1ca5c3e3a6e0abebdbcf"}, 575 | {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb5829d027ff82aa872d76158919045a7c1e91fbf241aec32cb07956e9ebd3c9"}, 576 | {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1"}, 577 | {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:596f3cd67e1b950bc372c33f1a28a0692080625592ea6392987dba7f09f17a94"}, 578 | {file = "cryptography-36.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:30ee1eb3ebe1644d1c3f183d115a8c04e4e603ed6ce8e394ed39eea4a98469ac"}, 579 | {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee"}, 580 | {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca238ceb7ba0bdf6ce88c1b74a87bffcee5afbfa1e41e173b1ceb095b39add46"}, 581 | {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:ca28641954f767f9822c24e927ad894d45d5a1e501767599647259cbf030b903"}, 582 | {file = "cryptography-36.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316"}, 583 | {file = "cryptography-36.0.1.tar.gz", hash = "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638"}, 584 | ] 585 | docutils = [ 586 | {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, 587 | {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, 588 | ] 589 | idna = [ 590 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 591 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 592 | ] 593 | importlib-metadata = [ 594 | {file = "importlib_metadata-4.11.1-py3-none-any.whl", hash = "sha256:e0bc84ff355328a4adfc5240c4f211e0ab386f80aa640d1b11f0618a1d282094"}, 595 | {file = "importlib_metadata-4.11.1.tar.gz", hash = "sha256:175f4ee440a0317f6e8d81b7f8d4869f93316170a65ad2b007d2929186c8052c"}, 596 | ] 597 | iniconfig = [ 598 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 599 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 600 | ] 601 | jeepney = [ 602 | {file = "jeepney-0.7.1-py3-none-any.whl", hash = "sha256:1b5a0ea5c0e7b166b2f5895b91a08c14de8915afda4407fb5022a195224958ac"}, 603 | {file = "jeepney-0.7.1.tar.gz", hash = "sha256:fa9e232dfa0c498bd0b8a3a73b8d8a31978304dcef0515adc859d4e096f96f4f"}, 604 | ] 605 | keyring = [ 606 | {file = "keyring-23.5.0-py3-none-any.whl", hash = "sha256:b0d28928ac3ec8e42ef4cc227822647a19f1d544f21f96457965dc01cf555261"}, 607 | {file = "keyring-23.5.0.tar.gz", hash = "sha256:9012508e141a80bd1c0b6778d5c610dd9f8c464d75ac6774248500503f972fb9"}, 608 | ] 609 | packaging = [ 610 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 611 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 612 | ] 613 | pkginfo = [ 614 | {file = "pkginfo-1.8.2-py2.py3-none-any.whl", hash = "sha256:c24c487c6a7f72c66e816ab1796b96ac6c3d14d49338293d2141664330b55ffc"}, 615 | {file = "pkginfo-1.8.2.tar.gz", hash = "sha256:542e0d0b6750e2e21c20179803e40ab50598d8066d51097a0e382cba9eb02bff"}, 616 | ] 617 | pluggy = [ 618 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 619 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 620 | ] 621 | py = [ 622 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 623 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 624 | ] 625 | pycparser = [ 626 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 627 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 628 | ] 629 | pygments = [ 630 | {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, 631 | {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, 632 | ] 633 | pyparsing = [ 634 | {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, 635 | {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, 636 | ] 637 | pytest = [ 638 | {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, 639 | {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, 640 | ] 641 | pywin32-ctypes = [ 642 | {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, 643 | {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, 644 | ] 645 | readme-renderer = [ 646 | {file = "readme_renderer-32.0-py3-none-any.whl", hash = "sha256:a50a0f2123a4c1145ac6f420e1a348aafefcc9211c846e3d51df05fe3d865b7d"}, 647 | {file = "readme_renderer-32.0.tar.gz", hash = "sha256:b512beafa6798260c7d5af3e1b1f097e58bfcd9a575da7c4ddd5e037490a5b85"}, 648 | ] 649 | requests = [ 650 | {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, 651 | {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, 652 | ] 653 | requests-toolbelt = [ 654 | {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, 655 | {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, 656 | ] 657 | rfc3986 = [ 658 | {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"}, 659 | {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"}, 660 | ] 661 | secretstorage = [ 662 | {file = "SecretStorage-3.3.1-py3-none-any.whl", hash = "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f"}, 663 | {file = "SecretStorage-3.3.1.tar.gz", hash = "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195"}, 664 | ] 665 | six = [ 666 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 667 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 668 | ] 669 | tomli = [ 670 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 671 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 672 | ] 673 | tqdm = [ 674 | {file = "tqdm-4.62.3-py2.py3-none-any.whl", hash = "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c"}, 675 | {file = "tqdm-4.62.3.tar.gz", hash = "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d"}, 676 | ] 677 | twine = [ 678 | {file = "twine-3.8.0-py3-none-any.whl", hash = "sha256:d0550fca9dc19f3d5e8eadfce0c227294df0a2a951251a4385797c8a6198b7c8"}, 679 | {file = "twine-3.8.0.tar.gz", hash = "sha256:8efa52658e0ae770686a13b675569328f1fba9837e5de1867bfe5f46a9aefe19"}, 680 | ] 681 | typing-extensions = [ 682 | {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, 683 | {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, 684 | ] 685 | urllib3 = [ 686 | {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, 687 | {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, 688 | ] 689 | webencodings = [ 690 | {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, 691 | {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, 692 | ] 693 | zipp = [ 694 | {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, 695 | {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, 696 | ] 697 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fractional-indexing" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Jakub Roztocil "] 6 | 7 | [tool.poetry.dependencies] 8 | python = ">=3.7, <4" 9 | coverage = "^6.3.2" 10 | 11 | [tool.poetry.dev-dependencies] 12 | pytest = "^7.0.1" 13 | twine = "^3.8.0" 14 | 15 | 16 | [build-system] 17 | requires = ["poetry-core>=1.0.0"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import setup 4 | 5 | from fractional_indexing import __version__, __doc__, __licence__ 6 | 7 | ROOT = Path(__file__).parent 8 | 9 | 10 | setup( 11 | name='fractional-indexing', 12 | description=__doc__.strip(), 13 | long_description=(ROOT / 'README.md').read_text().strip(), 14 | long_description_content_type='text/markdown', 15 | version=__version__, 16 | license=__licence__, 17 | url='https://github.com/httpie/fractional-indexing-python', 18 | py_modules=[ 19 | 'fractional_indexing', 20 | ], 21 | install_requires=[ 22 | 'setuptools', 23 | ], 24 | project_urls={ 25 | 'GitHub': 'https://github.com/httpie/fractional-indexing-python', 26 | 'Twitter': 'https://twitter.com/httpie', 27 | 'Discord': 'https://httpie.io/discord', 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pytest 4 | 5 | from fractional_indexing import FIError, generate_key_between, generate_n_keys_between, validate_order_key 6 | 7 | 8 | BASE_95_DIGITS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' 9 | 10 | 11 | @pytest.mark.parametrize(['a', 'b', 'expected'], [ 12 | (None, None, 'a0'), 13 | (None, 'a0', 'Zz'), 14 | (None, 'Zz', 'Zy'), 15 | ('a0', None, 'a1'), 16 | ('a1', None, 'a2'), 17 | ('a0', 'a1', 'a0V'), 18 | ('a1', 'a2', 'a1V'), 19 | ('a0V', 'a1', 'a0l'), 20 | ('Zz', 'a0', 'ZzV'), 21 | ('Zz', 'a1', 'a0'), 22 | (None, 'Y00', 'Xzzz'), 23 | ('bzz', None, 'c000'), 24 | ('a0', 'a0V', 'a0G'), 25 | ('a0', 'a0G', 'a08'), 26 | ('b125', 'b129', 'b127'), 27 | ('a0', 'a1V', 'a1'), 28 | ('Zz', 'a01', 'a0'), 29 | (None, 'a0V', 'a0'), 30 | (None, 'b999', 'b99'), 31 | (None, 'A00000000000000000000000000', FIError('invalid order key: A00000000000000000000000000')), 32 | (None, 'A000000000000000000000000001', 'A000000000000000000000000000V'), 33 | ('zzzzzzzzzzzzzzzzzzzzzzzzzzy', None, 'zzzzzzzzzzzzzzzzzzzzzzzzzzz'), 34 | ('zzzzzzzzzzzzzzzzzzzzzzzzzzz', None, 'zzzzzzzzzzzzzzzzzzzzzzzzzzzV'), 35 | ('a00', None, FIError('invalid order key: a00')), 36 | ('a00', 'a1', FIError('invalid order key: a00')), 37 | ('0', '1', FIError('invalid order key head: 0')), 38 | ('a1', 'a0', FIError('a1 >= a0')), 39 | ]) 40 | def test_generate_key_between(a: Optional[str], b: Optional[str], expected: str) -> None: 41 | if isinstance(expected, FIError): 42 | with pytest.raises(FIError) as e: 43 | generate_key_between(a, b) 44 | assert e.value.args[0] == expected.args[0] 45 | else: 46 | act = generate_key_between(a, b) 47 | print(f'exp: {expected}') 48 | print(f'act: {act}') 49 | print(act == expected) 50 | assert act == expected 51 | 52 | 53 | @pytest.mark.parametrize(['a', 'b', 'n', 'expected'], [ 54 | (None, None, 5, 'a0 a1 a2 a3 a4'), 55 | ('a4', None, 10, 'a5 a6 a7 a8 a9 b00 b01 b02 b03 b04'), 56 | (None, 'a0', 5, 'Z5 Z6 Z7 Z8 Z9'), 57 | ('a0', 'a2', 20, 'a01 a02 a03 a035 a04 a05 a06 a07 a08 a09 a1 a11 a12 a13 a14 a15 a16 a17 a18 a19'), 58 | ]) 59 | def test_generate_n_keys_between(a: Optional[str], b: Optional[str], n: int, expected: str) -> None: 60 | base_10_digits = '0123456789' 61 | act = ' '.join(generate_n_keys_between(a, b, n, base_10_digits)) 62 | print() 63 | print(f'exp: {expected}') 64 | print(f'act: {act}') 65 | print(act == expected) 66 | assert act == expected 67 | 68 | 69 | @pytest.mark.parametrize(['a', 'b', 'expected'], [ 70 | ('a00', 'a01', 'a00P'), 71 | ('a0/', 'a00', 'a0/P'), 72 | (None, None, 'a '), 73 | ('a ', None, 'a!'), 74 | (None, 'a ', 'Z~'), 75 | ('a0 ', 'a0!', FIError('invalid order key: a0 ')), 76 | (None, 'A 0', 'A ('), 77 | ('a~', None, 'b '), 78 | ('Z~', None, 'a '), 79 | ('b ', None, FIError('invalid order key: b ')), 80 | ('a0', 'a0V', 'a0;'), 81 | ('a 1', 'a 2', 'a 1P'), 82 | (None, 'A ', FIError('invalid order key: A ')), 83 | ]) 84 | def test_base95_digits(a: Optional[str], b: Optional[str], expected: str) -> None: 85 | kwargs = { 86 | 'a': a, 87 | 'b': b, 88 | 'digits': BASE_95_DIGITS, 89 | } 90 | if isinstance(expected, FIError): 91 | with pytest.raises(FIError) as e: 92 | generate_key_between(**kwargs) 93 | assert e.value.args[0] == expected.args[0] 94 | else: 95 | act = generate_key_between(**kwargs) 96 | print() 97 | print(f'exp: {expected}') 98 | print(f'act: {act}') 99 | print(act == expected) 100 | assert act == expected 101 | 102 | 103 | def test_readme_examples_single_key(): 104 | # Insert at the beginning 105 | first = generate_key_between(None, None) 106 | assert first == 'a0' 107 | 108 | # Insert after 1st 109 | second = generate_key_between(first, None) 110 | assert second == 'a1' 111 | 112 | # Insert after 2nd 113 | third = generate_key_between(second, None) 114 | assert third == 'a2' 115 | 116 | # Insert before 1st 117 | zeroth = generate_key_between(None, first) 118 | assert zeroth == 'Zz' 119 | 120 | # Insert in between 2nd and 3rd. Midpoint 121 | second_and_half = generate_key_between(second, third) 122 | assert second_and_half == 'a1V' 123 | 124 | 125 | def test_readme_examples_multiple_keys(): 126 | # Insert 3 at the beginning 127 | keys = generate_n_keys_between(None, None, n=3) 128 | assert keys == ['a0', 'a1', 'a2'] 129 | 130 | # Insert 3 after 1st 131 | keys = generate_n_keys_between('a0', None, n=3) 132 | assert keys == ['a1', 'a2', 'a3'] 133 | 134 | # Insert 3 before 1st 135 | keys = generate_n_keys_between(None, 'a0', n=3) 136 | assert keys == ['Zx', 'Zy', 'Zz'] 137 | 138 | # Insert 3 in between 2nd and 3rd. Midpoint 139 | keys = generate_n_keys_between('a1', 'a2', n=3) 140 | assert keys == ['a1G', 'a1V', 'a1l'] 141 | 142 | 143 | def test_readme_examples_validate_order_key(): 144 | from fractional_indexing import validate_order_key, FIError 145 | 146 | validate_order_key('a0') 147 | 148 | try: 149 | validate_order_key('foo') 150 | except FIError as e: 151 | print(e) # fractional_indexing.FIError: invalid order key: foo 152 | 153 | 154 | def test_readme_examples_custom_base(): 155 | validate_order_key('a ', digits=BASE_95_DIGITS) 156 | assert generate_key_between(None, None, digits=BASE_95_DIGITS) == 'a ' 157 | assert generate_key_between('a ', None, digits=BASE_95_DIGITS) == 'a!' 158 | assert generate_key_between(None, 'a ', digits=BASE_95_DIGITS) == 'Z~' 159 | assert generate_n_keys_between('a ', 'a!', n=3, digits=BASE_95_DIGITS) == ['a 8', 'a P', 'a h'] 160 | --------------------------------------------------------------------------------