diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad5f218..ea7fb09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,14 @@ on: [push, pull_request] jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v2 - - run: make install - - run: make test + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install pytest + - run: pytest tests.py --verbose diff --git a/README.md b/README.md index 76ef3d0..5e984c6 100644 --- a/README.md +++ b/README.md @@ -89,28 +89,48 @@ except FIError as e: ### Use custom base digits By default, this library uses Base62 character encoding. To use a different set of digits, pass them in as the `digits` -argument to `generate_key_between()`, `generate_n_keys_between()`, and `validate_order_key()`: +argument to `generate_key_between()`, `generate_n_keys_between()`, and `validate_order_key()`. + +Every key starts with a "head" character that encodes the length of its integer part. Since v0.2.0 (matching the +JS reference v4.0.0), the head alphabet (`int_digits`) defaults to `digits` itself, so a custom alphabet produces +self-contained keys drawn only from that alphabet: + +```python +from fractional_indexing import generate_key_between + + +assert generate_key_between(None, None, digits='0123456789') == '50' +assert generate_key_between('50', None, digits='0123456789') == '51' + +``` + +To keep the pre-0.2 behaviour (`A-Z`/`a-z` head markers, e.g. `a0`), pass `BASE_52_DIGITS` as `int_digits`. +An odd-length alphabet such as Base95 cannot supply its own (even-length) head alphabet, so it must be paired +with an explicit `int_digits`: ```python -from fractional_indexing import generate_key_between, generate_n_keys_between, validate_order_key +from fractional_indexing import BASE_52_DIGITS, generate_key_between, generate_n_keys_between, validate_order_key BASE_95_DIGITS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' -assert generate_key_between(None, None, digits=BASE_95_DIGITS) == 'a ' -assert generate_key_between('a ', None, digits=BASE_95_DIGITS) == 'a!' -assert generate_key_between(None, 'a ', digits=BASE_95_DIGITS) == 'Z~' +assert generate_key_between(None, None, digits=BASE_95_DIGITS, int_digits=BASE_52_DIGITS) == 'a ' +assert generate_key_between('a ', None, digits=BASE_95_DIGITS, int_digits=BASE_52_DIGITS) == 'a!' +assert generate_key_between(None, 'a ', digits=BASE_95_DIGITS, int_digits=BASE_52_DIGITS) == 'Z~' -assert generate_n_keys_between('a ', 'a!', n=3, digits=BASE_95_DIGITS) == ['a"', 'a#', 'a$'] +assert generate_n_keys_between('a ', 'a!', n=3, digits=BASE_95_DIGITS, int_digits=BASE_52_DIGITS) == ['a 8', 'a P', 'a h'] -validate_order_key('a ', digits=BASE_95_DIGITS) +validate_order_key('a ', digits=BASE_95_DIGITS, int_digits=BASE_52_DIGITS) ``` +Alphabets are validated: they must be at least two characters, single-byte (char code 0-255), and in strictly +ascending character-code order; `int_digits` must also have even length. Invalid alphabets raise `FIError`. + ## Other Languages -This is a Python port of the original JavaScript implementation by [@rocicorp](https://github.com/rocicorp). That means -that this implementation is byte-for-byte compatible with: +This is a Python port of the original JavaScript implementation by [@rocicorp](https://github.com/rocicorp) +(as of its v4.0.0). That means that this implementation is byte-for-byte compatible with: | Language | Repo | |------------|-------------------------------------------------------| @@ -118,3 +138,22 @@ that this implementation is byte-for-byte compatible with: | Go | https://github.com/rocicorp/fracdex | | Kotlin | https://github.com/darvelo/fractional-indexing-kotlin | | Ruby | https://github.com/kazu-2020/fractional_indexer | + +## Changelog + +### 0.2.0 + +Brings the library to parity with [rocicorp/fractional-indexing](https://github.com/rocicorp/fractional-indexing) +v4.0.0: + +- **Breaking**: the head alphabet now defaults to `digits` itself, so custom alphabets produce self-contained keys + (e.g. `generate_key_between(None, None, digits='0123456789')` returns `'50'`, not `'a0'`). Pass + `int_digits=BASE_52_DIGITS` to restore the previous `A-Z`/`a-z` head markers. Keys generated with the default + Base62 alphabet are unchanged. +- New `int_digits` argument on `generate_key_between()`, `generate_n_keys_between()`, and `validate_order_key()` + to customise the head alphabet, plus a new `BASE_52_DIGITS` export. +- `generate_key_between()` now accepts its bounds in either order and swaps them, instead of raising `FIError`. +- Alphabets are validated (length, ascending character-code order, single-byte); invalid alphabets and unknown + digits now raise `FIError` consistently instead of leaking `ValueError`. +- Digit lookups are cached per alphabet, and the midpoint calculation uses integer arithmetic (the `decimal` + dependency is gone). diff --git a/fractional_indexing.py b/fractional_indexing.py index 7bbc722..9efd22b 100644 --- a/fractional_indexing.py +++ b/fractional_indexing.py @@ -3,67 +3,159 @@ . - +Python port of (v4.0.0), +which is based on +. """ +from __future__ import annotations + +from functools import lru_cache from math import floor -from typing import Optional, List -import decimal +from typing import List, Optional -__version__ = '0.1.3' +__version__ = '0.2.0' __licence__ = 'CC0 1.0 Universal' +__all__ = [ + 'BASE_62_DIGITS', + 'BASE_52_DIGITS', + 'FIError', + 'generate_key_between', + 'generate_n_keys_between', + 'validate_order_key', +] + BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +# The classic head-marker alphabet (A-Z + a-z), used as the default `int_digits` +# when `digits` is omitted. Pass it explicitly to keep the pre-0.2 "a0"-style +# heads with a custom digit alphabet. +BASE_52_DIGITS = BASE_62_DIGITS[10:] + class FIError(Exception): pass -def midpoint(a: str, b: Optional[str], digits: str) -> str: +@lru_cache(maxsize=None) +def _digit_index(digits: str) -> dict: + """ + Per-alphabet map of each digit to its index, so digit->value lookups are a + single dict access instead of O(alphabet) ``str.index`` calls. Lookups use + ``.get(char, 0)``, mirroring the reference JS implementation's Uint8Array + table which returns 0 for characters outside the alphabet. + """ + return {c: i for i, c in enumerate(digits)} + + +def _is_strictly_ascending(s: str) -> bool: + """ + True if every character has a strictly greater character code than the one + before it (ascending order, which also rules out duplicates). + """ + return all(ord(s[i - 1]) < ord(s[i]) for i in range(1, len(s))) + + +def _is_single_byte(s: str) -> bool: + """ + True if every character is single-byte (code point 0-255). Keys are required + to be single-byte so that they stay compatible with the reference JS + implementation, whose lookup tables only cover char codes 0-255. + """ + return all(ord(c) <= 255 for c in s) + + +@lru_cache(maxsize=None) +def _validate_digits(digits: str) -> None: + """ + Validates a fractional-digit alphabet: at least two characters, in strictly + ascending character-code order. Cached per alphabet: validation is pure and + its result never changes, so each alphabet is only scanned once. + (``lru_cache`` does not cache raised exceptions, only the success path.) + """ + if len(digits) < 2 or not _is_strictly_ascending(digits): + raise FIError( + f'digits must be at least 2 characters in strictly ascending character code order: {digits}' + ) + if not _is_single_byte(digits): + raise FIError(f'digits must be single-byte (char code 0-255): {digits}') + + +@lru_cache(maxsize=None) +def _validate_int_digits(int_digits: str) -> None: + """ + Validates a head-marker alphabet: an even number of at least two characters + (its two halves are the negative- and positive-length heads), in strictly + ascending character-code order. + """ + if len(int_digits) < 2 or len(int_digits) % 2 != 0 or not _is_strictly_ascending(int_digits): + raise FIError( + 'int_digits must be an even number of at least 2 characters in strictly ' + f'ascending character code order: {int_digits}' + ) + if not _is_single_byte(int_digits): + raise FIError(f'int_digits must be single-byte (char code 0-255): {int_digits}') + + +def _resolve_alphabets(digits: Optional[str], int_digits: Optional[str]) -> tuple: + """ + Applies the default resolution shared by every public entry point: + `int_digits` defaults to `digits`, and when `digits` is also omitted it + falls back to BASE_52_DIGITS (A-Z/a-z) so the default keys keep the classic + "a0", "Zz", ... form. `digits` itself defaults to BASE_62_DIGITS. + + Both alphabets are always validated, including a defaulted `int_digits` + (the reference JS implementation skips that check and silently produces + broken keys for an odd-length `digits`; we raise instead). + """ + int_digits_defaulted = int_digits is None + if int_digits is not None: + _validate_int_digits(int_digits) + else: + int_digits = digits if digits is not None else BASE_52_DIGITS + if digits is not None: + _validate_digits(digits) + else: + digits = BASE_62_DIGITS + if int_digits_defaulted: + _validate_int_digits(int_digits) + return digits, int_digits + + +def _midpoint(a: str, b: Optional[str], digits: str, lookup: dict) -> str: """ `a` may be empty string, `b` is null or non-empty string. `a < b` lexicographically if `b` is non-null. no trailing zeros allowed. - digits is a string such as '0123456789' for base 10. Digits must be in - ascending character code order! """ zero = digits[0] if b is not None and a >= b: raise FIError(f'{a} >= {b}') - if (a and a[-1]) == zero or (b is not None and b[-1] == zero): + if (a and a[-1] == zero) or (b is not None and b[-1] == zero): raise FIError('trailing zero') if b: # remove longest common prefix. pad `a` with 0s as we # go. note that we don't need to pad `b`, because it can't # end before `a` while traversing the common prefix. n = 0 - for x, y in zip(a.ljust(len(b), zero), b): - if x == y: - n += 1 - continue - break - + while n < len(b) and (a[n] if n < len(a) else zero) == b[n]: + n += 1 if n > 0: - return b[:n] + midpoint(a[n:], b[n:], digits) + return b[:n] + _midpoint(a[n:], b[n:], digits, lookup) # first digits (or lack of digit) are different - try: - digit_a = digits.index(a[0]) if a else 0 - except IndexError: - digit_a = -1 - try: - digit_b = digits.index(b[0]) if b is not None else len(digits) - except IndexError: - digit_b = -1 - + digit_a = lookup.get(a[0], 0) if a else 0 + digit_b = lookup.get(b[0], 0) if b is not None else len(digits) if digit_b - digit_a > 1: - min_digit = round_half_up(0.5 * (digit_a + digit_b)) - return digits[min_digit] + # round half up, matching JS Math.round() + mid_digit = (digit_a + digit_b + 1) // 2 + return digits[mid_digit] else: + # first digits are consecutive if b is not None and len(b) > 1: return b[:1] else: @@ -73,213 +165,287 @@ def midpoint(a: str, b: Optional[str], digits: str) -> str: # given, for example, midpoint('49', '5'), return # '4' + midpoint('9', null), which will become # '4' + '9' + midpoint('', null), which is '495' - return digits[digit_a] + midpoint(a[1:], None, digits) + return digits[digit_a] + _midpoint(a[1:], None, digits, lookup) -def validate_integer(i: str): - if len(i) != get_integer_length(i[0]): +def _validate_integer(i: str, int_digits: str, int_lookup: dict) -> None: + if len(i) != _get_integer_length(i[0], int_digits, int_lookup): raise FIError(f'invalid integer part of order key: {i}') -def get_integer_length(head): - if 'a' <= head <= 'z': - return ord(head) - ord('a') + 2 - elif 'A' <= head <= 'Z': - return ord('Z') - ord(head[0]) + 2 - raise FIError('invalid order key head: ' + head) +def _get_integer_length(head: str, int_digits: str, int_lookup: dict) -> int: + """ + `int_digits` is a single lexicographically ordered (ascending) alphabet: the + first half are the negative-length heads and the second half the + positive-length heads (the default A-Z/a-z markers are just one such + alphabet). The outermost characters mark the longest integer parts, and the + two heads straddling the midpoint mark the shortest (length 2). + """ + i = int_lookup.get(head, 0) + # `.get` returns 0 for characters outside the alphabet, so confirm the + # character really is at index `i` before trusting it as a head. + if int_digits[i] == head: + half = len(int_digits) // 2 + return half - i + 1 if i < half else i - half + 2 + raise FIError(f'invalid order key head: {head}') -def get_integer_part(key: str) -> str: - integer_part_length = get_integer_length(key[0]) +def _get_integer_part(key: str, int_digits: str, int_lookup: dict) -> str: + integer_part_length = _get_integer_length(key[0], int_digits, int_lookup) if integer_part_length > len(key): raise FIError(f'invalid order key: {key}') return key[:integer_part_length] -def validate_order_key(key: str, digits=BASE_62_DIGITS): - zero = digits[0] - smallest = 'A' + (zero * 26) - if key == smallest: - raise FIError(f'invalid order key: {key}') +@lru_cache(maxsize=None) +def _smallest_integer(digits: str, int_digits: str) -> str: + """ + The smallest integer is the most-negative head (the first character of + `int_digits`, marking the longest integer part) followed by all-zero digits. + """ + return int_digits[0] + digits[0] * (len(int_digits) // 2) - # get_integer_part() will throw if the first character is bad, + +def _validate_order_key(key: str, digits: str, int_digits: str, int_lookup: dict) -> None: + if key == _smallest_integer(digits, int_digits): + raise FIError(f'invalid order key: {key}') + # _get_integer_part() will throw if the first character is bad, # or the key is too short. we'd call it to check these things # even if we didn't need the result - i = get_integer_part(key) + i = _get_integer_part(key, int_digits, int_lookup) f = key[len(i):] - if f and f[-1] == zero: + if f and f[-1] == digits[0]: raise FIError(f'invalid order key: {key}') -def increment_integer(x: str, digits: str) -> Optional[str]: +def _increment_integer( + x: str, digits: str, lookup: dict, int_digits: str, int_lookup: dict, +) -> Optional[str]: + """ + note that this may return None, as there is a largest integer + + """ + _validate_integer(x, int_digits, int_lookup) + head = x[0] zero = digits[0] - validate_integer(x) - head, *digs = x - carry = True - for i in reversed(range(len(digs))): - d = digits.index(digs[i]) + 1 + # Walk the digit run right-to-left, turning maxed-out digits into zeros + # (`trailing`) until we find one we can bump. + trailing = '' + for i in range(len(x) - 1, 0, -1): + d = lookup.get(x[i], 0) + 1 if d == len(digits): - digs[i] = zero - else: - digs[i] = digits[d] - carry = False - break - if carry: - if head == 'Z': - return 'a' + zero - elif head == 'z': - return None - h = chr(ord(head[0]) + 1) - if h > 'a': - digs.append(zero) + trailing = zero + trailing else: - digs.pop() - return h + ''.join(digs) - else: - return head + ''.join(digs) - + return x[:i] + digits[d] + trailing + # carry out of the whole digit run; `trailing` is now all zeros. + head_index = int_lookup.get(head, 0) + if head_index == len(int_digits) - 1: + # already the largest integer + return None + h = int_digits[head_index + 1] + # the head moves one step toward the largest digit; grow or shrink the digit + # run to match the new head's integer length. + length_delta = ( + _get_integer_length(h, int_digits, int_lookup) + - _get_integer_length(head, int_digits, int_lookup) + ) + if length_delta > 0: + return h + trailing + zero + if length_delta < 0: + return h + trailing[1:] + return h + trailing -def decrement_integer(x, digits): - validate_integer(x) - head, *digs = x - borrow = True - for i in reversed(range(len(digs))): - try: - index = digits.index(digs[i]) - except IndexError: - index = -1 - d = index - 1 +def _decrement_integer( + x: str, digits: str, lookup: dict, int_digits: str, int_lookup: dict, +) -> Optional[str]: + """ + note that this may return None, as there is a smallest integer + """ + _validate_integer(x, int_digits, int_lookup) + head = x[0] + last = digits[-1] + # Walk the digit run right-to-left, turning underflowed digits into the + # largest digit (`trailing`) until we find one we can drop. + trailing = '' + for i in range(len(x) - 1, 0, -1): + d = lookup.get(x[i], 0) - 1 if d == -1: - digs[i] = digits[-1] - else: - digs[i] = digits[d] - borrow = False - break - if borrow: - if head == 'a': - return 'Z' + digits[-1] - if head == 'A': - return None - h = chr(ord(head[0]) - 1) - if h < 'Z': - digs.append(digits[-1]) + trailing = last + trailing else: - digs.pop() - return h + ''.join(digs) - else: - return head + ''.join(digs) + return x[:i] + digits[d] + trailing + # borrow out of the whole digit run; `trailing` is now all max digits. + head_index = int_lookup.get(head, 0) + if head_index == 0: + # already the smallest integer + return None + h = int_digits[head_index - 1] + # the head moves one step toward the smallest digit; grow or shrink the + # digit run to match the new head's integer length. + length_delta = ( + _get_integer_length(h, int_digits, int_lookup) + - _get_integer_length(head, int_digits, int_lookup) + ) + if length_delta > 0: + return h + trailing + last + if length_delta < 0: + return h + trailing[1:] + return h + trailing + + +def validate_order_key(key: str, digits: Optional[str] = None, int_digits: Optional[str] = None) -> None: + """ + Validates that `key` is a well-formed order key for the given alphabets. + Raises FIError if it is not. Alphabet defaults are resolved the same way as + in `generate_key_between()`. + """ + digits, int_digits = _resolve_alphabets(digits, int_digits) + _validate_order_key(key, digits, int_digits, _digit_index(int_digits)) -def generate_key_between(a: Optional[str], b: Optional[str], digits=BASE_62_DIGITS) -> str: +def generate_key_between( + a: Optional[str], + b: Optional[str], + digits: Optional[str] = None, + int_digits: Optional[str] = None, +) -> str: """ - `a` is an order key or null (START). - `b` is an order key or null (END). - `a < b` lexicographically if both are non-null. - digits is a string such as '0123456789' for base 10. Digits must be in - ascending character code order! + Generates an order key that sorts between `a` and `b`. + + `a` is the lower bound: an order key, or None for the start. + `b` is the upper bound: an order key, or None for the end. + When both are non-None, they may be passed in either order. + + `digits` is the alphabet, e.g. '0123456789' for base 10. Its characters + must be single-byte (char code 0-255) and in ascending character code + order; both are validated. It may otherwise be any alphabet (it does not + need to contain 0-9, A-Z or a-z). Because `int_digits` defaults to + `digits`, an odd-length `digits` must be paired with an explicit + even-length `int_digits`. + + Note that `digits` only defines the *digit values* of a key. The integer + part of every key also begins with a length/magnitude marker (a "head") + drawn from the `int_digits` alphabet. The head only ever occupies the first + position and is only compared against other heads, which is why `digits` + and `int_digits` may overlap (or be identical) and keys still sort + correctly. + + `int_digits` is the head alphabet: a single alphabet in ascending + (lexicographical) character order, with even length. Its first half are the + negative-length heads and its second half the positive-length heads. The + outermost characters mark the longest integer parts and the two characters + straddling the midpoint mark the shortest (length 2). The integer part may + grow until it reaches the outermost heads, so a shorter alphabet limits how + large/small a key's integer part can become. + + `int_digits` defaults to `digits`, so a base-10 alphabet produces + self-headed keys like "50", "600" or "49". When `digits` is also omitted it + falls back to BASE_52_DIGITS (A-Z/a-z), giving the classic "a0", "b00", + "Z9" form. Note that passing `digits` explicitly (even BASE_62_DIGITS) + makes the keys self-headed; only omitting `digits` entirely yields the + A-Z/a-z heads. + + >>> generate_key_between(None, None) + 'a0' + >>> generate_key_between(None, None, '0123456789') + '50' """ - zero = digits[0] + digits, int_digits = _resolve_alphabets(digits, int_digits) + lookup = _digit_index(digits) + int_lookup = _digit_index(int_digits) if a is not None: - validate_order_key(a, digits=digits) + _validate_order_key(a, digits, int_digits, int_lookup) if b is not None: - validate_order_key(b, digits=digits) - if a is not None and b is not None and a >= b: - raise FIError(f'{a} >= {b}') + _validate_order_key(b, digits, int_digits, int_lookup) + if a is not None and b is not None and a > b: + # swap if out of order, so that a < b. this is just a convenience for + # callers, and doesn't affect the properties of the generated key. + a, b = b, a if a is None: if b is None: - return 'a' + zero - ib = get_integer_part(b) + # the shortest positive head: the first character of the second + # half of int_digits ("a" for the default A-Z/a-z markers). + head = int_digits[len(int_digits) // 2] + return head + digits[0] + ib = _get_integer_part(b, int_digits, int_lookup) fb = b[len(ib):] - if ib == 'A' + (zero * 26): - return ib + midpoint('', fb, digits) + if ib == _smallest_integer(digits, int_digits): + return ib + _midpoint('', fb, digits, lookup) if ib < b: return ib - res = decrement_integer(ib, digits) + res = _decrement_integer(ib, digits, lookup, int_digits, int_lookup) if res is None: raise FIError('cannot decrement any more') return res if b is None: - ia = get_integer_part(a) + ia = _get_integer_part(a, int_digits, int_lookup) fa = a[len(ia):] - i = increment_integer(ia, digits) - return ia + midpoint(fa, None, digits) if i is None else i + i = _increment_integer(ia, digits, lookup, int_digits, int_lookup) + return ia + _midpoint(fa, None, digits, lookup) if i is None else i - ia = get_integer_part(a) + ia = _get_integer_part(a, int_digits, int_lookup) fa = a[len(ia):] - ib = get_integer_part(b) + ib = _get_integer_part(b, int_digits, int_lookup) fb = b[len(ib):] if ia == ib: - return ia + midpoint(fa, fb, digits) - i = increment_integer(ia, digits) + return ia + _midpoint(fa, fb, digits, lookup) + i = _increment_integer(ia, digits, lookup, int_digits, int_lookup) if i is None: raise FIError('cannot increment any more') - if i < b: return i - - return ia + midpoint(fa, None, digits) + return ia + _midpoint(fa, None, digits, lookup) -def generate_n_keys_between(a: Optional[str], b: Optional[str], n: int, digits=BASE_62_DIGITS) -> List[str]: +def generate_n_keys_between( + a: Optional[str], + b: Optional[str], + n: int, + digits: Optional[str] = None, + int_digits: Optional[str] = None, +) -> List[str]: """ - same preconditions as generate_keys_between(). - n >= 0. + same preconditions as generate_key_between(). + n must be >= 0 (raises FIError otherwise). Returns an array of n distinct keys in sorted order. If a and b are both null, returns [a0, a1, ...] If one or the other is null, returns consecutive "integer" - keys. Otherwise, returns relatively short keys between + keys. Otherwise, returns relatively short keys between `a` and `b`. """ + digits, int_digits = _resolve_alphabets(digits, int_digits) + if n < 0: + # Without this guard a negative n silently returns a single key when + # one bound is None, and recurses without bound when both are set. + raise FIError(f'n must be >= 0: {n}') if n == 0: return [] if n == 1: - return [generate_key_between(a, b, digits)] + return [generate_key_between(a, b, digits, int_digits)] if b is None: - c = generate_key_between(a, b, digits) + c = generate_key_between(a, b, digits, int_digits) result = [c] - for i in range(n - 1): - c = generate_key_between(c, b, digits) + for _ in range(n - 1): + c = generate_key_between(c, b, digits, int_digits) result.append(c) return result if a is None: - c = generate_key_between(a, b, digits) + c = generate_key_between(a, b, digits, int_digits) result = [c] - for i in range(n - 1): - c = generate_key_between(a, c, digits) + for _ in range(n - 1): + c = generate_key_between(a, c, digits, int_digits) result.append(c) return list(reversed(result)) mid = floor(n / 2) - c = generate_key_between(a, b, digits) + c = generate_key_between(a, b, digits, int_digits) return [ - *generate_n_keys_between(a, c, mid, digits), + *generate_n_keys_between(a, c, mid, digits, int_digits), c, - *generate_n_keys_between(c, b, n - mid - 1, digits) + *generate_n_keys_between(c, b, n - mid - 1, digits, int_digits), ] - - -def round_half_up(n: float) -> int: - """ - >>> round_half_up(0.4) - 0 - >>> round_half_up(0.8) - 1 - >>> round_half_up(0.5) - 1 - >>> round_half_up(1.5) - 2 - >>> round_half_up(2.5) - 3 - """ - return int( - decimal.Decimal(str(n)).quantize( - decimal.Decimal('1'), - rounding=decimal.ROUND_HALF_UP - ) - ) diff --git a/tests.py b/tests.py index 44fec6b..8d6893c 100644 --- a/tests.py +++ b/tests.py @@ -2,12 +2,40 @@ import pytest -from fractional_indexing import FIError, generate_key_between, generate_n_keys_between, validate_order_key +from fractional_indexing import ( + BASE_52_DIGITS, + BASE_62_DIGITS, + FIError, + generate_key_between, + generate_n_keys_between, + validate_order_key, +) +BASE_10_DIGITS = '0123456789' BASE_95_DIGITS = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' +def check_key(expected, a, b, digits=None, int_digits=None): + if isinstance(expected, FIError): + with pytest.raises(FIError) as e: + generate_key_between(a, b, digits, int_digits) + assert e.value.args[0] == expected.args[0] + else: + assert generate_key_between(a, b, digits, int_digits) == expected + + +def check_n_keys(expected, a, b, n, digits=None, int_digits=None): + if isinstance(expected, FIError): + with pytest.raises(FIError) as e: + generate_n_keys_between(a, b, n, digits, int_digits) + assert e.value.args[0] == expected.args[0] + else: + assert ' '.join(generate_n_keys_between(a, b, n, digits, int_digits)) == expected + + +# With no `digits` and no `int_digits` the default is unchanged: BASE_62 digits +# with the A-Z/a-z head markers, so keys still look like 'a0', 'Zz', ... @pytest.mark.parametrize(['a', 'b', 'expected'], [ (None, None, 'a0'), (None, 'a0', 'Zz'), @@ -35,37 +63,28 @@ ('a00', None, FIError('invalid order key: a00')), ('a00', 'a1', FIError('invalid order key: a00')), ('0', '1', FIError('invalid order key head: 0')), - ('a1', 'a0', FIError('a1 >= a0')), + # bounds may be passed in either order; they are swapped as a convenience + ('a1', 'a0', 'a0V'), ]) -def test_generate_key_between(a: Optional[str], b: Optional[str], expected: str) -> None: - if isinstance(expected, FIError): - with pytest.raises(FIError) as e: - generate_key_between(a, b) - assert e.value.args[0] == expected.args[0] - else: - act = generate_key_between(a, b) - print(f'exp: {expected}') - print(f'act: {act}') - print(act == expected) - assert act == expected +def test_generate_key_between(a: Optional[str], b: Optional[str], expected) -> None: + check_key(expected, a, b) +# A custom `digits` with no `int_digits` uses `digits` itself as the head +# alphabet, so base-10 keys contain no letters: heads 0-4 are negative lengths, +# 5-9 positive, and 4/5 mark the shortest (length-2) integer parts. @pytest.mark.parametrize(['a', 'b', 'n', 'expected'], [ - (None, None, 5, 'a0 a1 a2 a3 a4'), - ('a4', None, 10, 'a5 a6 a7 a8 a9 b00 b01 b02 b03 b04'), - (None, 'a0', 5, 'Z5 Z6 Z7 Z8 Z9'), - ('a0', 'a2', 20, 'a01 a02 a03 a035 a04 a05 a06 a07 a08 a09 a1 a11 a12 a13 a14 a15 a16 a17 a18 a19'), + (None, None, 5, '50 51 52 53 54'), + ('54', None, 10, '55 56 57 58 59 600 601 602 603 604'), + (None, '50', 5, '45 46 47 48 49'), + ('50', '52', 20, '501 502 503 5035 504 505 506 507 508 509 51 511 512 513 514 515 516 517 518 519'), ]) -def test_generate_n_keys_between(a: Optional[str], b: Optional[str], n: int, expected: str) -> None: - base_10_digits = '0123456789' - act = ' '.join(generate_n_keys_between(a, b, n, base_10_digits)) - print() - print(f'exp: {expected}') - print(f'act: {act}') - print(act == expected) - assert act == expected +def test_generate_n_keys_between_base_10(a: Optional[str], b: Optional[str], n: int, expected: str) -> None: + check_n_keys(expected, a, b, n, BASE_10_DIGITS) +# base-95 is odd-length, so it can't supply its own (even) head alphabet; +# pass the default A-Z/a-z markers explicitly to keep Latin heads. @pytest.mark.parametrize(['a', 'b', 'expected'], [ ('a00', 'a01', 'a00P'), ('a0/', 'a00', 'a0/P'), @@ -81,23 +100,165 @@ def test_generate_n_keys_between(a: Optional[str], b: Optional[str], n: int, exp ('a 1', 'a 2', 'a 1P'), (None, 'A ', FIError('invalid order key: A ')), ]) -def test_base95_digits(a: Optional[str], b: Optional[str], expected: str) -> None: - kwargs = { - 'a': a, - 'b': b, - 'digits': BASE_95_DIGITS, - } - if isinstance(expected, FIError): - with pytest.raises(FIError) as e: - generate_key_between(**kwargs) - assert e.value.args[0] == expected.args[0] - else: - act = generate_key_between(**kwargs) - print() - print(f'exp: {expected}') - print(f'act: {act}') - print(act == expected) - assert act == expected +def test_base95_digits(a: Optional[str], b: Optional[str], expected) -> None: + check_key(expected, a, b, BASE_95_DIGITS, BASE_52_DIGITS) + + +# Custom alphabets that do not contain the Latin head characters a-z/A-Z work +# fine, as long as the digits are even-length, single-byte (char code 0-255), +# and sorted in ascending character-code order. With no `int_digits` the digit +# alphabet itself supplies the integer heads, so the generated keys are +# self-headed (no Latin letters). +@pytest.mark.parametrize(['digits', 'a', 'b', 'n', 'expected'], [ + # Base 2: the integer range is tiny (only heads '0' and '1'). + ('01', None, None, 8, '10 11 111 1111 11111 111111 1111111 11111111'), + ('01', '10', None, 1, '11'), + ('01', '10', '11', 1, '101'), + # Keys must be single-byte: a multi-byte alphabet (e.g. Greek, U+0391..) is + # rejected, but Latin-1 characters (char code 128-255) are allowed. + ('ΑΒΓΔΕΖΗΘ', None, None, 10, FIError('digits must be single-byte (char code 0-255): ΑΒΓΔΕΖΗΘ')), + # A Latin-1 alphabet (¡=161 .. ¦=166), all within the single-byte range. + ('¡¢£¤¥¦', None, None, 6, '¤¡ ¤¢ ¤£ ¤¤ ¤¥ ¤¦'), + # An alphabet of symbols whose character codes are all below 'A'. + (' !#$%&', None, None, 6, '$ $! $# $$ $% $&'), +]) +def test_custom_alphabets(digits: str, a: Optional[str], b: Optional[str], n: int, expected) -> None: + check_n_keys(expected, a, b, n, digits) + + +# `int_digits` overrides the integer-part head alphabet (which defaults to the +# digit alphabet, or to the A-Z/a-z markers when no `digits` is given). It is a +# single ascending (lexicographically ordered) alphabet: the first half are the +# negative-length heads and the second half the positive-length heads. +# Restricting it to a shorter alphabet limits how long the integer part may +# grow, and any head outside `int_digits` is invalid. +@pytest.mark.parametrize(['digits', 'int_digits', 'a', 'b', 'expected'], [ + # Limit negative heads to 'A','B' and positive heads to 'a','b'; the inner + # pair (B, a) mark length 2 and the outer pair (A, b) mark length 3. + (BASE_10_DIGITS, 'ABab', 'a0', 'a1', 'a05'), + (BASE_10_DIGITS, 'ABab', 'a9', None, 'b00'), + (BASE_10_DIGITS, 'ABab', 'b00', None, 'b01'), + (BASE_10_DIGITS, 'ABab', 'a0', None, 'a1'), + (BASE_10_DIGITS, 'ABab', None, 'B9', 'B8'), + # A head outside the limited alphabet is rejected. + (BASE_10_DIGITS, 'ABab', 'c00', None, FIError('invalid order key head: c')), + (BASE_10_DIGITS, 'ABab', '00', '01', FIError('invalid order key head: 0')), + # `int_digits` may be identical to `digits` (which is also the default), + # producing keys with no letters at all: the first half (0-4) are negative + # heads and the second half (5-9) positive heads, so 4 and 5 mark the + # shortest (length-2) integer parts. + (BASE_10_DIGITS, BASE_10_DIGITS, None, None, '50'), + (BASE_10_DIGITS, BASE_10_DIGITS, '50', None, '51'), + (BASE_10_DIGITS, BASE_10_DIGITS, '59', None, '600'), + (BASE_10_DIGITS, BASE_10_DIGITS, None, '50', '49'), + (BASE_10_DIGITS, BASE_10_DIGITS, '56', '57', '565'), +]) +def test_int_digits(digits: str, int_digits: str, a: Optional[str], b: Optional[str], expected) -> None: + check_key(expected, a, b, digits, int_digits) + + +# `digits` and `int_digits` are validated once at the start of the public API. +# `digits` must be at least two characters in strictly ascending character-code +# order (which also rules out duplicates). `int_digits` must additionally be of +# even length (its two halves are the negative- and positive-length heads). +@pytest.mark.parametrize(['digits', 'int_digits', 'expected'], [ + ('0213456789', 'ABab', + FIError('digits must be at least 2 characters in strictly ascending character code order: 0213456789')), + ('0', 'ABab', + FIError('digits must be at least 2 characters in strictly ascending character code order: 0')), + ('0012', 'ABab', + FIError('digits must be at least 2 characters in strictly ascending character code order: 0012')), + (BASE_10_DIGITS, 'abc', + FIError('int_digits must be an even number of at least 2 characters in strictly ascending character code order: abc')), + (BASE_10_DIGITS, 'ba', + FIError('int_digits must be an even number of at least 2 characters in strictly ascending character code order: ba')), + (BASE_10_DIGITS, '', + FIError('int_digits must be an even number of at least 2 characters in strictly ascending character code order: ')), + (BASE_10_DIGITS, 'ΑΒΓΔ', + FIError('int_digits must be single-byte (char code 0-255): ΑΒΓΔ')), + # An odd-length `digits` with no explicit `int_digits` cannot supply its own + # (even-length) head alphabet. (The reference JS implementation skips this + # check and silently produces broken keys; we raise instead.) + (BASE_95_DIGITS, None, + FIError('int_digits must be an even number of at least 2 characters in strictly ' + f'ascending character code order: {BASE_95_DIGITS}')), +]) +def test_alphabet_validation(digits: str, int_digits: Optional[str], expected: FIError) -> None: + check_key(expected, None, None, digits, int_digits) + check_n_keys(expected, None, None, 5, digits, int_digits) + + +class LCG: + """Deterministic pseudo-random generator so test runs are reproducible.""" + + def __init__(self, seed: int = 1) -> None: + self.seed = seed + + def random(self) -> float: + self.seed = (self.seed * 1103515245 + 12345) & 0x7FFFFFFF + return self.seed / 0x7FFFFFFF + + +# Generated keys must sort with ordinary lexicographic comparison for any +# ascending alphabet. Insert at random positions and verify ordering holds. +@pytest.mark.parametrize(['digits', 'int_digits'], [ + (BASE_10_DIGITS, None), + (' !#$%&', None), + ('¡¢£¤¥¦', None), + (BASE_62_DIGITS, None), + # base 2 self-heading has too small an integer range to sustain 1000 + # inserts, so give it the wider A-Z/a-z head alphabet. + ('01', BASE_52_DIGITS), + # digits and int_digits identical (base-10 keys with no letters). + (BASE_10_DIGITS, BASE_10_DIGITS), + # the all-defaults classic form. + (None, None), +]) +def test_ordering(digits: Optional[str], int_digits: Optional[str]) -> None: + rnd = LCG() + keys = [] + for _ in range(1000): + pos = int(rnd.random() * (len(keys) + 1)) + a = keys[pos - 1] if pos > 0 else None + b = keys[pos] if pos < len(keys) else None + k = generate_key_between(a, b, digits, int_digits) + assert (a is None or a < k) and (b is None or k < b), f'out of range: {a} < {k} < {b}' + keys.insert(pos, k) + assert keys == sorted(keys) + + +def test_omitted_int_digits_matches_digits() -> None: + # An omitted `int_digits` defaults to `digits`, so it must behave + # identically to passing `digits` as the head alphabet. + rnd = LCG() + keys = [] + for _ in range(2000): + pos = int(rnd.random() * (len(keys) + 1)) + a = keys[pos - 1] if pos > 0 else None + b = keys[pos] if pos < len(keys) else None + default = generate_key_between(a, b, BASE_10_DIGITS) + explicit = generate_key_between(a, b, BASE_10_DIGITS, BASE_10_DIGITS) + assert default == explicit + keys.insert(pos, default) + + +def test_equal_bounds_rejected() -> None: + with pytest.raises(FIError): + generate_key_between('a0', 'a0') + + +# A negative n must raise, not silently return a single key (one bound None) +# or recurse without bound (both bounds set). +@pytest.mark.parametrize(['a', 'b'], [ + (None, None), + ('a0', None), + (None, 'a1'), + ('a0', 'a5'), +]) +def test_negative_n_rejected(a: Optional[str], b: Optional[str]) -> None: + with pytest.raises(FIError) as e: + generate_n_keys_between(a, b, -1) + assert e.value.args[0] == 'n must be >= 0: -1' def test_readme_examples_single_key(): @@ -141,19 +302,17 @@ def test_readme_examples_multiple_keys(): def test_readme_examples_validate_order_key(): - from fractional_indexing import validate_order_key, FIError - validate_order_key('a0') - try: + with pytest.raises(FIError) as e: validate_order_key('foo') - except FIError as e: - print(e) # fractional_indexing.FIError: invalid order key: foo + assert e.value.args[0] == 'invalid order key: foo' def test_readme_examples_custom_base(): - validate_order_key('a ', digits=BASE_95_DIGITS) - assert generate_key_between(None, None, digits=BASE_95_DIGITS) == 'a ' - assert generate_key_between('a ', None, digits=BASE_95_DIGITS) == 'a!' - assert generate_key_between(None, 'a ', digits=BASE_95_DIGITS) == 'Z~' - assert generate_n_keys_between('a ', 'a!', n=3, digits=BASE_95_DIGITS) == ['a 8', 'a P', 'a h'] + validate_order_key('a ', digits=BASE_95_DIGITS, int_digits=BASE_52_DIGITS) + kwargs = {'digits': BASE_95_DIGITS, 'int_digits': BASE_52_DIGITS} + assert generate_key_between(None, None, **kwargs) == 'a ' + assert generate_key_between('a ', None, **kwargs) == 'a!' + assert generate_key_between(None, 'a ', **kwargs) == 'Z~' + assert generate_n_keys_between('a ', 'a!', n=3, **kwargs) == ['a 8', 'a P', 'a h']