Remove NumberRange, say hello to intervals

This commit is contained in:
Konsta Vesterinen
2014-01-13 20:20:30 +02:00
parent edb7c11b10
commit 3087f10502
8 changed files with 80 additions and 501 deletions

View File

@@ -24,6 +24,7 @@ extras_require = {
'anyjson': ['anyjson>=0.3.3'], 'anyjson': ['anyjson>=0.3.3'],
'babel': ['Babel>=1.3'], 'babel': ['Babel>=1.3'],
'arrow': ['arrow>=0.3.4'], 'arrow': ['arrow>=0.3.4'],
'intervals': ['intervals>=0.2.0'],
'phone': [ 'phone': [
# The phonenumbers library has a split for 2.x and 3.x support. # The phonenumbers library has a split for 2.x and 3.x support.
'phonenumbers3k==5.6b1' if PY3 else 'phonenumbers<5.6b1' 'phonenumbers3k==5.6b1' if PY3 else 'phonenumbers<5.6b1'

View File

@@ -21,7 +21,6 @@ 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,
@@ -33,6 +32,7 @@ from .types import (
EmailType, EmailType,
instrumented_list, instrumented_list,
InstrumentedList, InstrumentedList,
IntRangeType,
IPAddressType, IPAddressType,
JSONType, JSONType,
LocaleType, LocaleType,
@@ -40,14 +40,16 @@ from .types import (
PasswordType, PasswordType,
PhoneNumber, PhoneNumber,
PhoneNumberType, PhoneNumberType,
NumberRangeRawType,
NumberRangeType,
ScalarListType, ScalarListType,
ScalarListException, ScalarListException,
TimezoneType, TimezoneType,
TSVectorType, TSVectorType,
URLType, URLType,
UUIDType, UUIDType,
INT4RANGE,
INT8RANGE,
DATERANGE,
NUMRANGE,
) )
@@ -88,10 +90,7 @@ __all__ = (
JSONType, JSONType,
LocaleType, LocaleType,
Merger, Merger,
NumberRange, IntRangeType,
NumberRangeException,
NumberRangeRawType,
NumberRangeType,
Password, Password,
PasswordType, PasswordType,
PhoneNumber, PhoneNumber,
@@ -105,5 +104,9 @@ __all__ = (
UUIDType, UUIDType,
database_exists, database_exists,
create_database, create_database,
drop_database drop_database,
INT4RANGE,
INT8RANGE,
DATERANGE,
NUMRANGE,
) )

View File

@@ -1,14 +1,8 @@
from .number_range import (
NumberRange, NumberRangeException, RangeBoundsException
)
from .weekday import WeekDay from .weekday import WeekDay
from .weekdays import WeekDays from .weekdays import WeekDays
__all__ = ( __all__ = (
NumberRange,
NumberRangeException,
RangeBoundsException,
WeekDay, WeekDay,
WeekDays WeekDays
) )

View File

@@ -1,309 +0,0 @@
# -*- coding: utf-8 -*-
from collections import Iterable
from decimal import Decimal
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 %s is bigger than max value %s.' % (
min_value,
max_value
)
def is_number(number):
return isinstance(number, (float, int, Decimal))
def parse_number(number):
if number is None or number == '':
return None
elif is_number(number):
return number
else:
return int(number)
@total_ordering
class NumberRange(object):
def __init__(self, *args):
"""
Parses given args and assigns lower and upper bound for this number
range.
1. Comma separated string argument
::
>>> range = NumberRange('[23, 45]')
>>> range.lower
23
>>> range.upper
45
>>> range = NumberRange('(23, 45]')
>>> range.lower_inc
False
>>> range = NumberRange('(23, 45)')
>>> range.lower_inc
False
>>> range.upper_inc
False
2. Sequence of arguments
::
>>> range = NumberRange(23, 45)
>>> range.lower
23
>>> range.upper
45
3. Lists and tuples as an argument
::
>>> range = NumberRange([23, 45])
>>> range.lower
23
>>> range.upper
45
>>> range.closed
True
>>> range = NumberRange((23, 45))
>>> range.lower
23
>>> range.closed
False
4. Integer argument
::
>>> range = NumberRange(34)
>>> range.lower == range.upper == 34
True
5. Object argument
::
>>> range = NumberRange(NumberRange(20, 30))
>>> range.lower
20
>>> range.upper
30
"""
if len(args) > 2:
raise NumberRangeException(
'NumberRange takes at most two arguments'
)
elif len(args) == 2:
self.parse_sequence(args)
else:
arg, = args
if isinstance(arg, six.integer_types):
self.parse_integer(arg)
elif isinstance(arg, six.string_types):
self.parse_string(arg)
elif isinstance(arg, Iterable):
self.parse_sequence(arg)
elif hasattr(arg, 'lower') and hasattr(arg, 'upper'):
self.parse_object(arg)
if self.lower > self.upper:
raise RangeBoundsException(self.lower, self.upper)
@property
def lower(self):
return self._lower
@lower.setter
def lower(self, value):
if value is None:
self._lower = -float('inf')
else:
self._lower = value
@property
def upper(self):
return self._upper
@upper.setter
def upper(self, value):
if value is None:
self._upper = float('inf')
else:
self._upper = value
@property
def open(self):
"""
Returns whether or not this object is an open interval.
::
range = NumberRange('(23, 45)')
range.open # True
range = NumberRange('[23, 45]')
range.open # False
"""
return not self.lower_inc and not self.upper_inc
@property
def closed(self):
"""
Returns whether or not this object is a closed interval.
::
range = NumberRange('(23, 45)')
range.closed # False
range = NumberRange('[23, 45]')
range.closed # True
"""
return self.lower_inc and self.upper_inc
def parse_object(self, obj):
self.lower = obj.lower
self.upper = obj.upper
self.lower_inc = obj.lower_inc
self.upper_inc = obj.upper_inc
def parse_string(self, value):
if ',' not in value:
self.parse_hyphen_range(value)
else:
self.parse_bounded_range(value)
def parse_sequence(self, seq):
lower, upper = seq
self.lower = parse_number(lower)
self.upper = parse_number(upper)
if isinstance(seq, tuple):
self.lower_inc = self.upper_inc = False
else:
self.lower_inc = self.upper_inc = True
def parse_integer(self, value):
self.lower = self.upper = value
self.lower_inc = self.upper_inc = True
def parse_bounded_range(self, value):
values = value.strip()[1:-1].split(',')
try:
lower, upper = map(
lambda a: parse_number(a.strip()), values
)
except ValueError as e:
raise NumberRangeException(e.message)
self.lower_inc = value[0] == '['
self.upper_inc = value[-1] == ']'
self.lower = lower
self.upper = upper
def parse_hyphen_range(self, value):
values = value.split('-')
if len(values) == 1:
self.lower = self.upper = parse_number(value.strip())
else:
try:
self.lower, self.upper = map(
lambda a: parse_number(a.strip()), values
)
except ValueError as e:
raise NumberRangeException(str(e))
self.lower_inc = self.upper_inc = True
@property
def normalized(self):
return '%s%s, %s%s' % (
'[' if self.lower_inc else '(',
self.lower if self.lower != -float('inf') else '',
self.upper if self.upper != float('inf') else '',
']' if self.upper_inc else ')'
)
def __eq__(self, other):
if isinstance(other, six.integer_types):
return self.lower == other == self.upper
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):
if isinstance(other, six.integer_types):
return self.lower > other and self.upper > 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

View File

@@ -9,8 +9,11 @@ from .ip_address import IPAddressType
from .json import JSONType from .json import JSONType
from .locale import LocaleType from .locale import LocaleType
from .number_range import ( from .number_range import (
NumberRangeRawType, INT4RANGE,
NumberRangeType, INT8RANGE,
DATERANGE,
NUMRANGE,
IntRangeType,
) )
from .password import Password, PasswordType from .password import Password, PasswordType
from .phone_number import PhoneNumber, PhoneNumberType from .phone_number import PhoneNumber, PhoneNumberType
@@ -33,8 +36,7 @@ __all__ = (
IPAddressType, IPAddressType,
JSONType, JSONType,
LocaleType, LocaleType,
NumberRangeRawType, IntRangeType,
NumberRangeType,
Password, Password,
PasswordType, PasswordType,
PhoneNumber, PhoneNumber,
@@ -47,7 +49,11 @@ __all__ = (
UUIDType, UUIDType,
WeekDay, WeekDay,
WeekDays, WeekDays,
WeekDaysType WeekDaysType,
INT4RANGE,
INT8RANGE,
DATERANGE,
NUMRANGE,
) )

View File

@@ -1,10 +1,13 @@
import six intervals = None
try:
import intervals
except ImportError:
pass
from sqlalchemy import types from sqlalchemy import types
from sqlalchemy_utils.primitives import NumberRange
from .scalar_coercible import ScalarCoercible from .scalar_coercible import ScalarCoercible
class NumberRangeRawType(types.UserDefinedType): class INT4RANGE(types.UserDefinedType):
""" """
Raw number range type, only supports PostgreSQL for now. Raw number range type, only supports PostgreSQL for now.
""" """
@@ -12,40 +15,55 @@ class NumberRangeRawType(types.UserDefinedType):
return 'int4range' return 'int4range'
class NumberRangeType(types.TypeDecorator, ScalarCoercible): class INT8RANGE(types.UserDefinedType):
def get_col_spec(self):
return 'int8range'
class NUMRANGE(types.UserDefinedType):
def get_col_spec(self):
return 'numrange'
class DATERANGE(types.UserDefinedType):
def get_col_spec(self):
return 'daterange'
class IntRangeType(types.TypeDecorator, ScalarCoercible):
""" """
NumberRangeType provides way for saving range of numbers into database. IntRangeType provides way for saving range of numbers into database.
Example :: Example ::
from sqlalchemy_utils import NumberRangeType, NumberRange from sqlalchemy_utils import IntRangeType
class Event(Base): class Event(Base):
__tablename__ = 'user' __tablename__ = 'user'
id = sa.Column(sa.Integer, autoincrement=True) id = sa.Column(sa.Integer, autoincrement=True)
name = sa.Column(sa.Unicode(255)) name = sa.Column(sa.Unicode(255))
estimated_number_of_persons = sa.Column(NumberRangeType) estimated_number_of_persons = sa.Column(IntRangeType)
party = Event(name=u'party') party = Event(name=u'party')
# we estimate the party to contain minium of 10 persons and at max # we estimate the party to contain minium of 10 persons and at max
# 100 persons # 100 persons
party.estimated_number_of_persons = NumberRange(10, 100) party.estimated_number_of_persons = [10, 100]
print party.estimated_number_of_persons print party.estimated_number_of_persons
# '10-100' # '10-100'
NumberRange supports some arithmetic operators: IntRange returns the values as IntInterval objects. These objects support many arithmetic operators:
:: ::
meeting = Event(name=u'meeting') meeting = Event(name=u'meeting')
meeting.estimated_number_of_persons = NumberRange(20, 40) meeting.estimated_number_of_persons = [20, 40]
total = ( total = (
meeting.estimated_number_of_persons + meeting.estimated_number_of_persons +
@@ -55,19 +73,19 @@ class NumberRangeType(types.TypeDecorator, ScalarCoercible):
# '30-140' # '30-140'
""" """
impl = NumberRangeRawType impl = INT4RANGE
def process_bind_param(self, value, dialect): def process_bind_param(self, value, dialect):
if value is not None: if value is not None:
return value.normalized return str(value)
return value return value
def process_result_value(self, value, dialect): def process_result_value(self, value, dialect):
if value: if value:
return NumberRange(value) return intervals.IntInterval(value)
return value return value
def _coerce(self, value): def _coerce(self, value):
if value is not None: if value is not None:
value = NumberRange(value) value = intervals.IntInterval(value)
return value return value

View File

@@ -1,146 +0,0 @@
from pytest import raises, mark
from sqlalchemy_utils.primitives import (
NumberRange, NumberRangeException, RangeBoundsException
)
class TestNumberRangeInit(object):
def test_support_range_object(self):
num_range = NumberRange(NumberRange(1, 3))
assert num_range.lower == 1
assert num_range.upper == 3
def test_supports_multiple_args(self):
num_range = NumberRange(1, 3)
assert num_range.lower == 1
assert num_range.upper == 3
def test_supports_strings(self):
num_range = NumberRange('1-3')
assert num_range.lower == 1
assert num_range.upper == 3
def test_supports_strings_with_spaces(self):
num_range = NumberRange('1 - 3')
assert num_range.lower == 1
assert num_range.upper == 3
def test_supports_strings_with_bounds(self):
num_range = NumberRange('[1, 3]')
assert num_range.lower == 1
assert num_range.upper == 3
def test_empty_string_as_upper_bound(self):
num_range = NumberRange('[1,)')
assert num_range.lower == 1
assert num_range.upper == float('inf')
def test_supports_exact_ranges_as_strings(self):
num_range = NumberRange('3')
assert num_range.lower == 3
assert num_range.upper == 3
def test_supports_integers(self):
num_range = NumberRange(3)
assert num_range.lower == 3
assert num_range.upper == 3
class TestComparisonOperators(object):
def test_eq_operator(self):
assert NumberRange(1, 3) == NumberRange(1, 3)
assert not NumberRange(1, 3) == NumberRange(1, 4)
def test_ne_operator(self):
assert not NumberRange(1, 3) != NumberRange(1, 3)
assert NumberRange(1, 3) != NumberRange(1, 4)
def test_gt_operator(self):
assert NumberRange(1, 3) > NumberRange(0, 2)
assert not NumberRange(2, 3) > NumberRange(2, 3)
def test_ge_operator(self):
assert NumberRange(1, 3) >= NumberRange(0, 2)
assert NumberRange(1, 3) >= NumberRange(1, 3)
def test_lt_operator(self):
assert NumberRange(0, 2) < NumberRange(1, 3)
assert not NumberRange(2, 3) < NumberRange(2, 3)
def test_le_operator(self):
assert NumberRange(0, 2) <= NumberRange(1, 3)
assert NumberRange(1, 3) >= NumberRange(1, 3)
def test_integer_comparison(self):
assert NumberRange(2, 2) <= 3
assert NumberRange(1, 3) >= 0
assert NumberRange(2, 2) == 2
assert NumberRange(2, 2) != 3
def test_str_representation():
assert str(NumberRange(1, 3)) == '1 - 3'
assert str(NumberRange(1, 1)) == '1'
@mark.parametrize('number_range',
(
(3, 2),
[4, 2],
'5-2',
(float('inf'), 2),
'[4, 3]',
)
)
def test_raises_exception_for_badly_constructed_range(number_range):
with raises(RangeBoundsException):
NumberRange(number_range)
@mark.parametrize(('number_range', 'is_open'),
(
((2, 3), True),
('(2, 5)', True),
('[3, 4)', False),
('(4, 5]', False),
('3 - 4', False),
([4, 5], False),
('[4, 5]', False)
)
)
def test_open(number_range, is_open):
assert NumberRange(number_range).open == is_open
@mark.parametrize(('number_range', 'is_closed'),
(
((2, 3), False),
('(2, 5)', False),
('[3, 4)', False),
('(4, 5]', False),
('3 - 4', True),
([4, 5], True),
('[4, 5]', True)
)
)
def test_closed(number_range, is_closed):
assert NumberRange(number_range).closed == is_closed
class TestArithmeticOperators(object):
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(-1, 2)
def test_isub_operator(self):
range_ = NumberRange(1, 3)
range_ -= NumberRange(1, 2)
assert range_ == NumberRange(-1, 2)
def test_iadd_operator(self):
range_ = NumberRange(1, 2)
range_ += NumberRange(1, 2)
assert range_ == NumberRange(2, 4)

View File

@@ -1,19 +1,21 @@
from pytest import mark from pytest import mark
import sqlalchemy as sa import sqlalchemy as sa
intervals = None
try:
import intervals
except ImportError:
pass
from tests import TestCase from tests import TestCase
from sqlalchemy_utils import ( from sqlalchemy_utils import IntRangeType
NumberRangeType,
NumberRange,
coercion_listener
)
class TestNumberRangeType(TestCase): @mark.skipif('intervals is None')
class NumberRangeTestCase(TestCase):
def create_models(self): def create_models(self):
class Building(self.Base): class Building(self.Base):
__tablename__ = 'building' __tablename__ = 'building'
id = sa.Column(sa.Integer, primary_key=True) id = sa.Column(sa.Integer, primary_key=True)
persons_at_night = sa.Column(NumberRangeType) persons_at_night = sa.Column(IntRangeType)
def __repr__(self): def __repr__(self):
return 'Building(%r)' % self.id return 'Building(%r)' % self.id
@@ -23,8 +25,8 @@ class TestNumberRangeType(TestCase):
@mark.parametrize( @mark.parametrize(
'number_range', 'number_range',
( (
(1, 3), [1, 3],
'1-3' '1 - 3',
) )
) )
def test_save_number_range(self, number_range): def test_save_number_range(self, number_range):
@@ -34,13 +36,14 @@ class TestNumberRangeType(TestCase):
self.session.add(building) self.session.add(building)
self.session.commit() self.session.commit()
self.session.expire(building)
building = self.session.query(self.Building).first() building = self.session.query(self.Building).first()
assert building.persons_at_night.lower == 1 assert building.persons_at_night.lower == 1
assert building.persons_at_night.upper == 3 assert building.persons_at_night.upper == 3
def test_infinite_upper_bound(self): def test_infinite_upper_bound(self):
building = self.Building( building = self.Building(
persons_at_night=NumberRange(1, float('inf')) persons_at_night=intervals.IntInterval(1, float('inf'))
) )
self.session.add(building) self.session.add(building)
self.session.commit() self.session.commit()
@@ -51,7 +54,7 @@ class TestNumberRangeType(TestCase):
def test_infinite_lower_bound(self): def test_infinite_lower_bound(self):
building = self.Building( building = self.Building(
persons_at_night=NumberRange(-float('inf'), 1) persons_at_night=intervals.IntInterval(-float('inf'), 1)
) )
self.session.add(building) self.session.add(building)
self.session.commit() self.session.commit()
@@ -61,7 +64,7 @@ class TestNumberRangeType(TestCase):
def test_nullify_number_range(self): def test_nullify_number_range(self):
building = self.Building( building = self.Building(
persons_at_night=NumberRange(1, 3) persons_at_night=intervals.IntInterval(1, 3)
) )
self.session.add(building) self.session.add(building)
@@ -77,9 +80,18 @@ class TestNumberRangeType(TestCase):
def test_string_coercion(self): def test_string_coercion(self):
building = self.Building(persons_at_night='[12, 18]') building = self.Building(persons_at_night='[12, 18]')
assert isinstance(building.persons_at_night, NumberRange) assert isinstance(building.persons_at_night, intervals.IntInterval)
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.lower == 15 assert building.persons_at_night.lower == 15
assert building.persons_at_night.upper == 15 assert building.persons_at_night.upper == 15
class TestNumberRangeTypeOnPostgres(NumberRangeTestCase):
dns = 'postgres://postgres@localhost/sqlalchemy_utils_test'
class TestNumberRangeTypeOnSqlite(NumberRangeTestCase):
pass