Added NumberRange class in primitives package (bunch of new methods also)
This commit is contained in:
@@ -22,6 +22,7 @@ from .functions import (
|
|||||||
from .listeners import coercion_listener
|
from .listeners import coercion_listener
|
||||||
from .merge import merge, Merger
|
from .merge import merge, Merger
|
||||||
from .generic import generic_relationship
|
from .generic import generic_relationship
|
||||||
|
from .primitives import NumberRange, NumberRangeException
|
||||||
from .proxy_dict import ProxyDict, proxy_dict
|
from .proxy_dict import ProxyDict, proxy_dict
|
||||||
from .types import (
|
from .types import (
|
||||||
ArrowType,
|
ArrowType,
|
||||||
@@ -39,8 +40,6 @@ from .types import (
|
|||||||
PasswordType,
|
PasswordType,
|
||||||
PhoneNumber,
|
PhoneNumber,
|
||||||
PhoneNumberType,
|
PhoneNumberType,
|
||||||
NumberRange,
|
|
||||||
NumberRangeException,
|
|
||||||
NumberRangeRawType,
|
NumberRangeRawType,
|
||||||
NumberRangeType,
|
NumberRangeType,
|
||||||
ScalarListType,
|
ScalarListType,
|
||||||
|
7
sqlalchemy_utils/primitives/__init__.py
Normal file
7
sqlalchemy_utils/primitives/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from .number_range import NumberRange, NumberRangeException
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
NumberRange,
|
||||||
|
NumberRangeException
|
||||||
|
)
|
162
sqlalchemy_utils/primitives/number_range.py
Normal file
162
sqlalchemy_utils/primitives/number_range.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
try:
|
||||||
|
from functools import total_ordering
|
||||||
|
except ImportError:
|
||||||
|
from total_ordering import total_ordering
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
|
|
||||||
|
class NumberRangeException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RangeBoundsException(NumberRangeException):
|
||||||
|
def __init__(self, min_value, max_value):
|
||||||
|
self.message = 'Min value %d is bigger than max value %d.' % (
|
||||||
|
min_value,
|
||||||
|
max_value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@total_ordering
|
||||||
|
class NumberRange(object):
|
||||||
|
def __init__(self, *args):
|
||||||
|
if len(args) > 2:
|
||||||
|
raise NumberRangeException(
|
||||||
|
'NumberRange takes at most two arguments'
|
||||||
|
)
|
||||||
|
elif len(args) == 2:
|
||||||
|
lower, upper = args
|
||||||
|
if lower > upper:
|
||||||
|
raise RangeBoundsException(lower, upper)
|
||||||
|
self.lower = lower
|
||||||
|
self.upper = upper
|
||||||
|
self.lower_inc = self.upper_inc = True
|
||||||
|
else:
|
||||||
|
if isinstance(args[0], six.integer_types):
|
||||||
|
self.lower = self.upper = args[0]
|
||||||
|
self.lower_inc = self.upper_inc = True
|
||||||
|
elif isinstance(args[0], six.string_types):
|
||||||
|
if ',' not in args[0]:
|
||||||
|
self.lower, self.upper = self.parse_range(args[0])
|
||||||
|
self.lower_inc = self.upper_inc = True
|
||||||
|
else:
|
||||||
|
self.from_range_with_bounds(args[0])
|
||||||
|
elif hasattr(args[0], 'lower') and hasattr(args[0], 'upper'):
|
||||||
|
self.lower = args[0].lower
|
||||||
|
self.upper = args[0].upper
|
||||||
|
if not args[0].lower_inc:
|
||||||
|
self.lower += 1
|
||||||
|
|
||||||
|
if not args[0].upper_inc:
|
||||||
|
self.upper -= 1
|
||||||
|
|
||||||
|
def from_range_with_bounds(self, value):
|
||||||
|
"""
|
||||||
|
Returns new NumberRange object from normalized number range format.
|
||||||
|
|
||||||
|
Example ::
|
||||||
|
|
||||||
|
range = NumberRange.from_normalized_str('[23, 45]')
|
||||||
|
range.lower = 23
|
||||||
|
range.upper = 45
|
||||||
|
|
||||||
|
range = NumberRange.from_normalized_str('(23, 45]')
|
||||||
|
range.lower = 24
|
||||||
|
range.upper = 45
|
||||||
|
|
||||||
|
range = NumberRange.from_normalized_str('(23, 45)')
|
||||||
|
range.lower = 24
|
||||||
|
range.upper = 44
|
||||||
|
"""
|
||||||
|
values = value[1:-1].split(',')
|
||||||
|
try:
|
||||||
|
lower, upper = map(
|
||||||
|
lambda a: int(a.strip()), values
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise NumberRangeException(e.message)
|
||||||
|
|
||||||
|
self.lower_inc = value[0] == '('
|
||||||
|
if self.lower_inc:
|
||||||
|
lower += 1
|
||||||
|
|
||||||
|
self.upper_inc = value[-1] == ')'
|
||||||
|
if self.upper_inc:
|
||||||
|
upper -= 1
|
||||||
|
|
||||||
|
self.lower = lower
|
||||||
|
self.upper = upper
|
||||||
|
|
||||||
|
def parse_range(self, value):
|
||||||
|
if value is not None:
|
||||||
|
values = value.split('-')
|
||||||
|
if len(values) == 1:
|
||||||
|
lower = upper = int(value.strip())
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
lower, upper = map(
|
||||||
|
lambda a: int(a.strip()), values
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise NumberRangeException(str(e))
|
||||||
|
return lower, upper
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalized(self):
|
||||||
|
return '[%s, %s]' % (self.lower, self.upper)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
self.lower == other.lower and
|
||||||
|
self.upper == other.upper
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not (self == other)
|
||||||
|
|
||||||
|
def __gt__(self, other):
|
||||||
|
try:
|
||||||
|
return self.lower > other.lower and self.upper > other.upper
|
||||||
|
except AttributeError:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'NumberRange(%r, %r)' % (self.lower, self.upper)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.lower != self.upper:
|
||||||
|
return '%s - %s' % (self.lower, self.upper)
|
||||||
|
return str(self.lower)
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
"""
|
||||||
|
[a, b] + [c, d] = [a + c, b + d]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return NumberRange(
|
||||||
|
self.lower + other.lower,
|
||||||
|
self.upper + other.upper
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
"""
|
||||||
|
Defines the substraction operator.
|
||||||
|
|
||||||
|
As defined in wikipedia:
|
||||||
|
|
||||||
|
[a, b] − [c, d] = [a − d, b − c]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return NumberRange(
|
||||||
|
self.lower - other.upper,
|
||||||
|
self.upper - other.lower
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
return NotImplemented
|
@@ -8,8 +8,6 @@ from .email import EmailType
|
|||||||
from .ip_address import IPAddressType
|
from .ip_address import IPAddressType
|
||||||
from .locale import LocaleType
|
from .locale import LocaleType
|
||||||
from .number_range import (
|
from .number_range import (
|
||||||
NumberRange,
|
|
||||||
NumberRangeException,
|
|
||||||
NumberRangeRawType,
|
NumberRangeRawType,
|
||||||
NumberRangeType,
|
NumberRangeType,
|
||||||
)
|
)
|
||||||
@@ -33,8 +31,6 @@ __all__ = (
|
|||||||
EmailType,
|
EmailType,
|
||||||
IPAddressType,
|
IPAddressType,
|
||||||
LocaleType,
|
LocaleType,
|
||||||
NumberRange,
|
|
||||||
NumberRangeException,
|
|
||||||
NumberRangeRawType,
|
NumberRangeRawType,
|
||||||
NumberRangeType,
|
NumberRangeType,
|
||||||
Password,
|
Password,
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import six
|
import six
|
||||||
from sqlalchemy import types
|
from sqlalchemy import types
|
||||||
|
from sqlalchemy_utils.primitives import NumberRange
|
||||||
from .scalar_coercible import ScalarCoercible
|
from .scalar_coercible import ScalarCoercible
|
||||||
|
|
||||||
|
|
||||||
@@ -64,155 +65,18 @@ class NumberRangeType(types.TypeDecorator, ScalarCoercible):
|
|||||||
def process_result_value(self, value, dialect):
|
def process_result_value(self, value, dialect):
|
||||||
if value:
|
if value:
|
||||||
if not isinstance(value, six.string_types):
|
if not isinstance(value, six.string_types):
|
||||||
value = NumberRange.from_range_object(value)
|
value = NumberRange(value)
|
||||||
else:
|
else:
|
||||||
return NumberRange.from_normalized_str(value)
|
return NumberRange(value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _coerce(self, value):
|
def _coerce(self, value):
|
||||||
if value is not None and not isinstance(value, NumberRange):
|
if value is not None and not isinstance(value, NumberRange):
|
||||||
if isinstance(value, six.string_types):
|
if (
|
||||||
value = NumberRange.from_normalized_str(value)
|
isinstance(value, six.string_types) or
|
||||||
elif isinstance(value, six.integer_types):
|
isinstance(value, six.integer_types)
|
||||||
value = NumberRange(value, value)
|
):
|
||||||
|
value = NumberRange(value)
|
||||||
else:
|
else:
|
||||||
raise TypeError('Could not coerce value to NumberRange.')
|
raise TypeError('Could not coerce value to NumberRange.')
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class NumberRangeException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RangeBoundsException(NumberRangeException):
|
|
||||||
def __init__(self, min_value, max_value):
|
|
||||||
self.message = 'Min value %d is bigger than max value %d.' % (
|
|
||||||
min_value,
|
|
||||||
max_value
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NumberRange(object):
|
|
||||||
def __init__(self, min_value, max_value):
|
|
||||||
if min_value > max_value:
|
|
||||||
raise RangeBoundsException(min_value, max_value)
|
|
||||||
self.min_value = min_value
|
|
||||||
self.max_value = max_value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_range_object(cls, value):
|
|
||||||
min_value = value.lower
|
|
||||||
max_value = value.upper
|
|
||||||
if not value.lower_inc:
|
|
||||||
min_value += 1
|
|
||||||
|
|
||||||
if not value.upper_inc:
|
|
||||||
max_value -= 1
|
|
||||||
|
|
||||||
return cls(min_value, max_value)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_normalized_str(cls, value):
|
|
||||||
"""
|
|
||||||
Returns new NumberRange object from normalized number range format.
|
|
||||||
|
|
||||||
Example ::
|
|
||||||
|
|
||||||
range = NumberRange.from_normalized_str('[23, 45]')
|
|
||||||
range.min_value = 23
|
|
||||||
range.max_value = 45
|
|
||||||
|
|
||||||
range = NumberRange.from_normalized_str('(23, 45]')
|
|
||||||
range.min_value = 24
|
|
||||||
range.max_value = 45
|
|
||||||
|
|
||||||
range = NumberRange.from_normalized_str('(23, 45)')
|
|
||||||
range.min_value = 24
|
|
||||||
range.max_value = 44
|
|
||||||
"""
|
|
||||||
if value is not None:
|
|
||||||
values = value[1:-1].split(',')
|
|
||||||
try:
|
|
||||||
min_value, max_value = map(
|
|
||||||
lambda a: int(a.strip()), values
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise NumberRangeException(e.message)
|
|
||||||
|
|
||||||
if value[0] == '(':
|
|
||||||
min_value += 1
|
|
||||||
|
|
||||||
if value[-1] == ')':
|
|
||||||
max_value -= 1
|
|
||||||
|
|
||||||
return cls(min_value, max_value)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_str(cls, value):
|
|
||||||
if value is not None:
|
|
||||||
values = value.split('-')
|
|
||||||
if len(values) == 1:
|
|
||||||
min_value = max_value = int(value.strip())
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
min_value, max_value = map(
|
|
||||||
lambda a: int(a.strip()), values
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise NumberRangeException(str(e))
|
|
||||||
return cls(min_value, max_value)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def normalized(self):
|
|
||||||
return '[%s, %s]' % (self.min_value, self.max_value)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
try:
|
|
||||||
return (
|
|
||||||
self.min_value == other.min_value and
|
|
||||||
self.max_value == other.max_value
|
|
||||||
)
|
|
||||||
except AttributeError:
|
|
||||||
return NotImplemented
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return 'NumberRange(%r, %r)' % (self.min_value, self.max_value)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
if self.min_value != self.max_value:
|
|
||||||
return '%s - %s' % (self.min_value, self.max_value)
|
|
||||||
return str(self.min_value)
|
|
||||||
|
|
||||||
def __add__(self, other):
|
|
||||||
try:
|
|
||||||
return NumberRange(
|
|
||||||
self.min_value + other.min_value,
|
|
||||||
self.max_value + other.max_value
|
|
||||||
)
|
|
||||||
except AttributeError:
|
|
||||||
return NotImplemented
|
|
||||||
|
|
||||||
def __iadd__(self, other):
|
|
||||||
try:
|
|
||||||
self.min_value += other.min_value
|
|
||||||
self.max_value += other.max_value
|
|
||||||
return self
|
|
||||||
except AttributeError:
|
|
||||||
return NotImplemented
|
|
||||||
|
|
||||||
def __sub__(self, other):
|
|
||||||
try:
|
|
||||||
return NumberRange(
|
|
||||||
self.min_value - other.min_value,
|
|
||||||
self.max_value - other.max_value
|
|
||||||
)
|
|
||||||
except AttributeError:
|
|
||||||
return NotImplemented
|
|
||||||
|
|
||||||
def __isub__(self, other):
|
|
||||||
try:
|
|
||||||
self.min_value -= other.min_value
|
|
||||||
self.max_value -= other.max_value
|
|
||||||
return self
|
|
||||||
except AttributeError:
|
|
||||||
return NotImplemented
|
|
||||||
|
@@ -1,10 +1,8 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from pytest import raises
|
|
||||||
from tests import TestCase
|
from tests import TestCase
|
||||||
from sqlalchemy_utils import (
|
from sqlalchemy_utils import (
|
||||||
NumberRangeType,
|
NumberRangeType,
|
||||||
NumberRange,
|
NumberRange,
|
||||||
NumberRangeException,
|
|
||||||
coercion_listener
|
coercion_listener
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +28,8 @@ class TestNumberRangeType(TestCase):
|
|||||||
self.session.add(building)
|
self.session.add(building)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
building = self.session.query(self.Building).first()
|
building = self.session.query(self.Building).first()
|
||||||
assert building.persons_at_night.min_value == 1
|
assert building.persons_at_night.lower == 1
|
||||||
assert building.persons_at_night.max_value == 3
|
assert building.persons_at_night.upper == 3
|
||||||
|
|
||||||
def test_nullify_number_range(self):
|
def test_nullify_number_range(self):
|
||||||
building = self.Building(
|
building = self.Building(
|
||||||
@@ -55,38 +53,5 @@ class TestNumberRangeType(TestCase):
|
|||||||
|
|
||||||
def test_integer_coercion(self):
|
def test_integer_coercion(self):
|
||||||
building = self.Building(persons_at_night=15)
|
building = self.Building(persons_at_night=15)
|
||||||
assert building.persons_at_night.min_value == 15
|
assert building.persons_at_night.lower == 15
|
||||||
assert building.persons_at_night.max_value == 15
|
assert building.persons_at_night.upper == 15
|
||||||
|
|
||||||
|
|
||||||
class TestNumberRange(object):
|
|
||||||
def test_equality_operator(self):
|
|
||||||
assert NumberRange(1, 3) == NumberRange(1, 3)
|
|
||||||
|
|
||||||
def test_str_representation(self):
|
|
||||||
assert str(NumberRange(1, 3)) == '1 - 3'
|
|
||||||
assert str(NumberRange(1, 1)) == '1'
|
|
||||||
|
|
||||||
def test_raises_exception_for_badly_constructed_range(self):
|
|
||||||
with raises(NumberRangeException):
|
|
||||||
NumberRange(3, 2)
|
|
||||||
|
|
||||||
def test_from_str_supports_single_integers(self):
|
|
||||||
number_range = NumberRange.from_str('1')
|
|
||||||
assert number_range.min_value == 1
|
|
||||||
assert number_range.max_value == 1
|
|
||||||
|
|
||||||
def test_from_str_exception_handling(self):
|
|
||||||
with raises(NumberRangeException):
|
|
||||||
NumberRange.from_str('1 - ')
|
|
||||||
|
|
||||||
def test_from_normalized_str(self):
|
|
||||||
assert str(NumberRange.from_normalized_str('[1,2]')) == '1 - 2'
|
|
||||||
assert str(NumberRange.from_normalized_str('[1,3)')) == '1 - 2'
|
|
||||||
assert str(NumberRange.from_normalized_str('(1,3)')) == '2'
|
|
||||||
|
|
||||||
def test_add_operator(self):
|
|
||||||
assert NumberRange(1, 2) + NumberRange(1, 2) == NumberRange(2, 4)
|
|
||||||
|
|
||||||
def test_sub_operator(self):
|
|
||||||
assert NumberRange(1, 3) - NumberRange(1, 2) == NumberRange(0, 1)
|
|
||||||
|
Reference in New Issue
Block a user