diff --git a/setup.py b/setup.py index f2a020d..741d45b 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ extras_require = { 'anyjson': ['anyjson>=0.3.3'], 'babel': ['Babel>=1.3'], 'arrow': ['arrow>=0.3.4'], + 'intervals': ['intervals>=0.2.0'], 'phone': [ # The phonenumbers library has a split for 2.x and 3.x support. 'phonenumbers3k==5.6b1' if PY3 else 'phonenumbers<5.6b1' diff --git a/sqlalchemy_utils/__init__.py b/sqlalchemy_utils/__init__.py index b6afde6..1e33c7e 100644 --- a/sqlalchemy_utils/__init__.py +++ b/sqlalchemy_utils/__init__.py @@ -21,7 +21,6 @@ from .functions import ( from .listeners import coercion_listener from .merge import merge, Merger from .generic import generic_relationship -from .primitives import NumberRange, NumberRangeException from .proxy_dict import ProxyDict, proxy_dict from .types import ( ArrowType, @@ -33,6 +32,7 @@ from .types import ( EmailType, instrumented_list, InstrumentedList, + IntRangeType, IPAddressType, JSONType, LocaleType, @@ -40,14 +40,16 @@ from .types import ( PasswordType, PhoneNumber, PhoneNumberType, - NumberRangeRawType, - NumberRangeType, ScalarListType, ScalarListException, TimezoneType, TSVectorType, URLType, UUIDType, + INT4RANGE, + INT8RANGE, + DATERANGE, + NUMRANGE, ) @@ -88,10 +90,7 @@ __all__ = ( JSONType, LocaleType, Merger, - NumberRange, - NumberRangeException, - NumberRangeRawType, - NumberRangeType, + IntRangeType, Password, PasswordType, PhoneNumber, @@ -105,5 +104,9 @@ __all__ = ( UUIDType, database_exists, create_database, - drop_database + drop_database, + INT4RANGE, + INT8RANGE, + DATERANGE, + NUMRANGE, ) diff --git a/sqlalchemy_utils/primitives/__init__.py b/sqlalchemy_utils/primitives/__init__.py index c570e85..c4c401b 100644 --- a/sqlalchemy_utils/primitives/__init__.py +++ b/sqlalchemy_utils/primitives/__init__.py @@ -1,14 +1,8 @@ -from .number_range import ( - NumberRange, NumberRangeException, RangeBoundsException -) from .weekday import WeekDay from .weekdays import WeekDays __all__ = ( - NumberRange, - NumberRangeException, - RangeBoundsException, WeekDay, WeekDays ) diff --git a/sqlalchemy_utils/primitives/number_range.py b/sqlalchemy_utils/primitives/number_range.py deleted file mode 100644 index ab5960a..0000000 --- a/sqlalchemy_utils/primitives/number_range.py +++ /dev/null @@ -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 diff --git a/sqlalchemy_utils/types/__init__.py b/sqlalchemy_utils/types/__init__.py index 7456458..20f476b 100644 --- a/sqlalchemy_utils/types/__init__.py +++ b/sqlalchemy_utils/types/__init__.py @@ -9,8 +9,11 @@ from .ip_address import IPAddressType from .json import JSONType from .locale import LocaleType from .number_range import ( - NumberRangeRawType, - NumberRangeType, + INT4RANGE, + INT8RANGE, + DATERANGE, + NUMRANGE, + IntRangeType, ) from .password import Password, PasswordType from .phone_number import PhoneNumber, PhoneNumberType @@ -33,8 +36,7 @@ __all__ = ( IPAddressType, JSONType, LocaleType, - NumberRangeRawType, - NumberRangeType, + IntRangeType, Password, PasswordType, PhoneNumber, @@ -47,7 +49,11 @@ __all__ = ( UUIDType, WeekDay, WeekDays, - WeekDaysType + WeekDaysType, + INT4RANGE, + INT8RANGE, + DATERANGE, + NUMRANGE, ) diff --git a/sqlalchemy_utils/types/number_range.py b/sqlalchemy_utils/types/number_range.py index dfba3b5..4d1c0b7 100644 --- a/sqlalchemy_utils/types/number_range.py +++ b/sqlalchemy_utils/types/number_range.py @@ -1,10 +1,13 @@ -import six +intervals = None +try: + import intervals +except ImportError: + pass from sqlalchemy import types -from sqlalchemy_utils.primitives import NumberRange from .scalar_coercible import ScalarCoercible -class NumberRangeRawType(types.UserDefinedType): +class INT4RANGE(types.UserDefinedType): """ Raw number range type, only supports PostgreSQL for now. """ @@ -12,40 +15,55 @@ class NumberRangeRawType(types.UserDefinedType): 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 :: - from sqlalchemy_utils import NumberRangeType, NumberRange + from sqlalchemy_utils import IntRangeType class Event(Base): __tablename__ = 'user' id = sa.Column(sa.Integer, autoincrement=True) 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') # we estimate the party to contain minium of 10 persons and at max # 100 persons - party.estimated_number_of_persons = NumberRange(10, 100) + party.estimated_number_of_persons = [10, 100] print party.estimated_number_of_persons # '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.estimated_number_of_persons = NumberRange(20, 40) + meeting.estimated_number_of_persons = [20, 40] total = ( meeting.estimated_number_of_persons + @@ -55,19 +73,19 @@ class NumberRangeType(types.TypeDecorator, ScalarCoercible): # '30-140' """ - impl = NumberRangeRawType + impl = INT4RANGE def process_bind_param(self, value, dialect): if value is not None: - return value.normalized + return str(value) return value def process_result_value(self, value, dialect): if value: - return NumberRange(value) + return intervals.IntInterval(value) return value def _coerce(self, value): if value is not None: - value = NumberRange(value) + value = intervals.IntInterval(value) return value diff --git a/tests/primitives/test_number_range.py b/tests/primitives/test_number_range.py deleted file mode 100644 index 6b9f70f..0000000 --- a/tests/primitives/test_number_range.py +++ /dev/null @@ -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) diff --git a/tests/types/test_number_range.py b/tests/types/test_number_range.py index b82c76a..fa8c260 100644 --- a/tests/types/test_number_range.py +++ b/tests/types/test_number_range.py @@ -1,19 +1,21 @@ from pytest import mark import sqlalchemy as sa +intervals = None +try: + import intervals +except ImportError: + pass from tests import TestCase -from sqlalchemy_utils import ( - NumberRangeType, - NumberRange, - coercion_listener -) +from sqlalchemy_utils import IntRangeType -class TestNumberRangeType(TestCase): +@mark.skipif('intervals is None') +class NumberRangeTestCase(TestCase): def create_models(self): class Building(self.Base): __tablename__ = 'building' id = sa.Column(sa.Integer, primary_key=True) - persons_at_night = sa.Column(NumberRangeType) + persons_at_night = sa.Column(IntRangeType) def __repr__(self): return 'Building(%r)' % self.id @@ -23,8 +25,8 @@ class TestNumberRangeType(TestCase): @mark.parametrize( 'number_range', ( - (1, 3), - '1-3' + [1, 3], + '1 - 3', ) ) def test_save_number_range(self, number_range): @@ -34,13 +36,14 @@ class TestNumberRangeType(TestCase): self.session.add(building) self.session.commit() + self.session.expire(building) building = self.session.query(self.Building).first() assert building.persons_at_night.lower == 1 assert building.persons_at_night.upper == 3 def test_infinite_upper_bound(self): building = self.Building( - persons_at_night=NumberRange(1, float('inf')) + persons_at_night=intervals.IntInterval(1, float('inf')) ) self.session.add(building) self.session.commit() @@ -51,7 +54,7 @@ class TestNumberRangeType(TestCase): def test_infinite_lower_bound(self): building = self.Building( - persons_at_night=NumberRange(-float('inf'), 1) + persons_at_night=intervals.IntInterval(-float('inf'), 1) ) self.session.add(building) self.session.commit() @@ -61,7 +64,7 @@ class TestNumberRangeType(TestCase): def test_nullify_number_range(self): building = self.Building( - persons_at_night=NumberRange(1, 3) + persons_at_night=intervals.IntInterval(1, 3) ) self.session.add(building) @@ -77,9 +80,18 @@ class TestNumberRangeType(TestCase): def test_string_coercion(self): 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): building = self.Building(persons_at_night=15) assert building.persons_at_night.lower == 15 assert building.persons_at_night.upper == 15 + + + +class TestNumberRangeTypeOnPostgres(NumberRangeTestCase): + dns = 'postgres://postgres@localhost/sqlalchemy_utils_test' + + +class TestNumberRangeTypeOnSqlite(NumberRangeTestCase): + pass