Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored codebase for improved clarity and readability #2

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# fractional_indexing/__init__.py

from fractional_indexing import (
generate_key_between,
generate_n_keys_between
)
from exceptions import OrderKeyError

__all__ = [
'generate_key_between',
'generate_n_keys_between',
'OrderKeyError'
]
5 changes: 5 additions & 0 deletions exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# fractional_indexing/exceptions.py

class OrderKeyError(Exception):
"""Custom error for invalid order keys."""
pass
340 changes: 93 additions & 247 deletions fractional_indexing.py
Original file line number Diff line number Diff line change
@@ -1,285 +1,131 @@
"""
Provides functions for generating ordering strings
# fractional_indexing/fractional_indexing.py

<https://github.com/httpie/fractional-indexing-python>.

<https://observablehq.com/@dgreensp/implementing-fractional-indexing>


"""
from math import floor
from typing import Optional, List
import decimal


__version__ = '0.1.3'
__licence__ = 'CC0 1.0 Universal'

BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'


class FIError(Exception):
pass

from exceptions import OrderKeyError
from utils import BASE_62_DIGITS, validate_order_key
from utils import (
get_integer_part,
find_middle_key,
decrement_integer,
increment_integer,
)

def midpoint(a: str, b: Optional[str], digits: str) -> 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!

"""
# GENERATE KEY BETWEEN HANDLERS AND FUNCTION
def handle_end_key_only_case(end_key: str, digits: str) -> str:
"""Handle the case when only `end_key` is provided."""
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):
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
integer_part = get_integer_part(end_key)
fractional_part = end_key[len(integer_part):]
if integer_part == 'A' + (zero * 26):
return integer_part + find_middle_key('', fractional_part, digits)
if integer_part < end_key:
return integer_part
decremented = decrement_integer(integer_part, digits)
if decremented is None:
raise OrderKeyError('Cannot decrement anymore')
return decremented

if n > 0:
return b[:n] + midpoint(a[n:], b[n:], digits)

# 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
def handle_start_key_only_case(start_key: str, digits: str) -> str:
"""Handle the case when only `start_key` is provided."""
integer_part = get_integer_part(start_key)
fractional_part = start_key[len(integer_part):]
incremented = increment_integer(integer_part, digits)
return integer_part + find_middle_key(fractional_part, None, digits) if incremented is None else incremented

if digit_b - digit_a > 1:
min_digit = round_half_up(0.5 * (digit_a + digit_b))
return digits[min_digit]
else:
if b is not None and len(b) > 1:
return b[:1]
else:
# `b` is null or has length 1 (a single digit).
# the first digit of `a` is the previous digit to `b`,
# or 9 if `b` is null.
# 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)

def handle_both_keys_case(start_key: str, end_key: str, digits: str) -> str:
"""Handle the case when both `start_key` and `end_key` are provided."""
start_int_part = get_integer_part(start_key)
start_frac_part = start_key[len(start_int_part):]
end_int_part = get_integer_part(end_key)
end_frac_part = end_key[len(end_int_part):]

def validate_integer(i: str):
if len(i) != get_integer_length(i[0]):
raise FIError(f'invalid integer part of order key: {i}')
if start_int_part == end_int_part:
return start_int_part + find_middle_key(start_frac_part, end_frac_part, digits)

incremented = increment_integer(start_int_part, digits)

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)
if incremented is None:
raise OrderKeyError('Cannot increment anymore')

if incremented < end_key:
return incremented

def get_integer_part(key: str) -> str:
integer_part_length = get_integer_length(key[0])
if integer_part_length > len(key):
raise FIError(f'invalid order key: {key}')
return key[:integer_part_length]
return start_int_part + find_middle_key(start_frac_part, None, digits)


def validate_order_key(key: str, digits=BASE_62_DIGITS):
def generate_key_between(start_key: Optional[str], end_key: Optional[str], digits: str = BASE_62_DIGITS) -> str:
"""
Generate an order key that lies between `start_key` and `end_key`.
If both are None, returns the first possible key.
"""
zero = digits[0]
smallest = 'A' + (zero * 26)
if key == smallest:
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)
f = key[len(i):]
if f and f[-1] == zero:
raise FIError(f'invalid order key: {key}')
if start_key is not None:
validate_order_key(start_key, digits)

if end_key is not None:
validate_order_key(end_key, digits)

def increment_integer(x: str, digits: str) -> Optional[str]:
zero = digits[0]
validate_integer(x)
head, *digs = x
carry = True
for i in reversed(range(len(digs))):
d = digits.index(digs[i]) + 1
if d == len(digits):
digs[i] = zero
else:
digs[i] = digits[d]
carry = False
break
if carry:
if head == 'Z':
if start_key is not None and end_key is not None and start_key >= end_key:
raise OrderKeyError(f'{start_key} >= {end_key}')

if start_key is None:
if end_key is None:
return 'a' + zero
elif head == 'z':
return None
h = chr(ord(head[0]) + 1)
if h > 'a':
digs.append(zero)
else:
digs.pop()
return h + ''.join(digs)
else:
return head + ''.join(digs)
return handle_end_key_only_case(end_key, digits)

if end_key is None:
return handle_start_key_only_case(start_key, digits)

def decrement_integer(x, digits):
validate_integer(x)
head, *digs = x
borrow = True
for i in reversed(range(len(digs))):
return handle_both_keys_case(start_key, end_key, digits)

try:
index = digits.index(digs[i])
except IndexError:
index = -1
d = index - 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])
else:
digs.pop()
return h + ''.join(digs)
else:
return head + ''.join(digs)
# GENERATE N KEYS BETWEEN HANDLERS AND FUNCTION
def handle_generate_n_keys_with_end_none(start_key: Optional[str], number_of_keys: int, digits: str) -> List[str]:
"""Handle case when generating keys with `end_key` as None."""
current_key = generate_key_between(start_key, None, digits)
result = [current_key]
for _ in range(number_of_keys - 1):
current_key = generate_key_between(current_key, None, digits)
result.append(current_key)
return result


def generate_key_between(a: Optional[str], b: Optional[str], digits=BASE_62_DIGITS) -> 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!
def handle_generate_n_keys_with_start_none(end_key: Optional[str], number_of_keys: int, digits: str) -> List[str]:
"""Handle case when generating keys with `start_key` as None."""
current_key = generate_key_between(None, end_key, digits)
result = [current_key]
for _ in range(number_of_keys - 1):
current_key = generate_key_between(None, current_key, digits)
result.append(current_key)
return list(reversed(result))

def generate_n_keys_between(start_key: Optional[str], end_key: Optional[str], number_of_keys: int, digits: str = BASE_62_DIGITS) -> List[str]:
"""
zero = digits[0]
if a is not None:
validate_order_key(a, digits=digits)
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}')

if a is None:
if b is None:
return 'a' + zero
ib = get_integer_part(b)
fb = b[len(ib):]
if ib == 'A' + (zero * 26):
return ib + midpoint('', fb, digits)
if ib < b:
return ib
res = decrement_integer(ib, digits)
if res is None:
raise FIError('cannot decrement any more')
return res

if b is None:
ia = get_integer_part(a)
fa = a[len(ia):]
i = increment_integer(ia, digits)
return ia + midpoint(fa, None, digits) if i is None else i

ia = get_integer_part(a)
fa = a[len(ia):]
ib = get_integer_part(b)
fb = b[len(ib):]
if ia == ib:
return ia + midpoint(fa, fb, digits)
i = increment_integer(ia, digits)
if i is None:
raise FIError('cannot increment any more')
Generate `number_of_keys` distinct order keys between `start_key` and `end_key`.
"""
if number_of_keys == 0:
return []

if i < b:
return i
if number_of_keys == 1:
return [generate_key_between(start_key, end_key, digits)]

return ia + midpoint(fa, None, digits)
if end_key is None:
return handle_generate_n_keys_with_end_none(start_key, number_of_keys, digits)

if start_key is None:
return handle_generate_n_keys_with_start_none(end_key, number_of_keys, digits)

def generate_n_keys_between(a: Optional[str], b: Optional[str], n: int, digits=BASE_62_DIGITS) -> List[str]:
"""
same preconditions as generate_keys_between().
n >= 0.
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
mid_index = floor(number_of_keys / 2)
middle_key = generate_key_between(start_key, end_key, digits)

"""
if n == 0:
return []
if n == 1:
return [generate_key_between(a, b, digits)]
if b is None:
c = generate_key_between(a, b, digits)
result = [c]
for i in range(n - 1):
c = generate_key_between(c, b, digits)
result.append(c)
return result

if a is None:
c = generate_key_between(a, b, digits)
result = [c]
for i in range(n - 1):
c = generate_key_between(a, c, digits)
result.append(c)
return list(reversed(result))

mid = floor(n / 2)
c = generate_key_between(a, b, digits)
return [
*generate_n_keys_between(a, c, mid, digits),
c,
*generate_n_keys_between(c, b, n - mid - 1, digits)
*generate_n_keys_between(start_key, middle_key, mid_index, digits),
middle_key,
*generate_n_keys_between(middle_key, end_key, number_of_keys - mid_index - 1, 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
)
)
Loading