Skip to content

Commit

Permalink
ENH: implement Time datatype
Browse files Browse the repository at this point in the history
implement BetweenTime node  closes #1098  closes #1096

Author: Jeff Reback <jeff@reback.net>

Closes #1105 from jreback/between and squashes the following commits:

e8d2bba [Jeff Reback] implement Time datatype imlpmenet BetweenTime node closes #1098
  • Loading branch information
jreback authored and cpcloud committed Aug 14, 2017
1 parent ecb119d commit 4990b56
Show file tree
Hide file tree
Showing 11 changed files with 236 additions and 15 deletions.
6 changes: 6 additions & 0 deletions ibis/compat.py
Expand Up @@ -69,7 +69,13 @@ def dict_values(x):
integer_types = six.integer_types + (np.integer,)


# pandas compat
try:
from pandas.api.types import DatetimeTZDtype # noqa: F401
except ImportError:
from pandas.types.dtypes import DatetimeTZDtype # noqa: F401

try:
from pandas.core.tools.datetimes import to_time # noqa: F401
except ImportError:
from pandas.tseries.tools import to_time # noqa: F401
62 changes: 57 additions & 5 deletions ibis/expr/api.py
Expand Up @@ -37,7 +37,7 @@
StringValue, StringScalar, StringColumn,
DecimalValue, DecimalScalar, DecimalColumn,
TimestampValue, TimestampScalar, TimestampColumn,
DateValue,
DateValue, TimeValue,
ArrayValue, ArrayScalar, ArrayColumn,
CategoryValue, unnamed, as_value_expr, literal,
null, sequence)
Expand All @@ -46,7 +46,7 @@
from ibis.expr.temporal import * # noqa

import ibis.common as _com

from ibis.compat import PY2, to_time
from ibis.expr.analytics import bucket, histogram
from ibis.expr.groupby import GroupedTableExpr # noqa
from ibis.expr.window import window, trailing_window, cumulative_window
Expand All @@ -59,7 +59,8 @@


__all__ = [
'schema', 'table', 'literal', 'expr_list', 'timestamp',
'schema', 'table', 'literal', 'expr_list',
'timestamp', 'time',
'case', 'where', 'sequence',
'now', 'desc', 'null', 'NA',
'cast', 'coalesce', 'greatest', 'least',
Expand Down Expand Up @@ -174,6 +175,26 @@ def timestamp(value):
return ir.TimestampScalar(ir.literal(value).op())


def time(value):
"""
Returns a time literal if value is likely coercible to a time
Parameters
----------
value : time value as string
Returns
--------
result : TimeScalar
"""
if PY2:
raise ValueError("time support is not enabled on python 2")

if isinstance(value, six.string_types):
value = to_time(value)
return ir.TimeScalar(ir.literal(value).op())


schema.__doc__ = """\
Validate and return an Ibis Schema object
Expand Down Expand Up @@ -643,7 +664,11 @@ def between(arg, lower, upper):
"""
lower = _ops.as_value_expr(lower)
upper = _ops.as_value_expr(upper)
op = _ops.Between(arg, lower, upper)

if isinstance(arg.op(), _ops.Time):
op = _ops.BetweenTime(arg.op().args[0], lower, upper)
else:
op = _ops.Between(arg, lower, upper)
return op.to_expr()


Expand Down Expand Up @@ -1774,6 +1799,22 @@ def _timestamp_strftime(arg, format_str):
return _ops.Strftime(arg, format_str).to_expr()


def _timestamp_time(arg):
"""
Return a Time node for a Timestamp
We can then perform certain operations on this node
w/o actually instantiating the underlying structure
(which is inefficient in pandas/numpy)
Returns
-------
Time node
"""
if PY2:
raise ValueError("time support is not enabled on python 2")
return _ops.Time(arg).to_expr()


_timestamp_value_methods = dict(
strftime=_timestamp_strftime,
year=_extract_field('year', _ops.ExtractYear),
Expand All @@ -1783,7 +1824,8 @@ def _timestamp_strftime(arg, format_str):
minute=_extract_field('minute', _ops.ExtractMinute),
second=_extract_field('second', _ops.ExtractSecond),
millisecond=_extract_field('millisecond', _ops.ExtractMillisecond),
truncate=_timestamp_truncate
truncate=_timestamp_truncate,
time=_timestamp_time,
)


Expand All @@ -1799,6 +1841,16 @@ def _timestamp_strftime(arg, format_str):
_add_methods(DateValue, _date_value_methods)


# ---------------------------------------------------------------------
# Time API

_time_value_methods = dict(
between=between,
)

_add_methods(TimeValue, _time_value_methods)


# ---------------------------------------------------------------------
# Decimal API

Expand Down
21 changes: 20 additions & 1 deletion ibis/expr/datatypes.py
Expand Up @@ -308,6 +308,12 @@ def valid_literal(self, value):
return isinstance(value, six.string_types + (datetime.date,))


class Time(Primitive):

def valid_literal(self, value):
return isinstance(value, six.string_types + (datetime.time,))


def parametric(cls):
type_name = cls.__name__
array_type_name = '{0}Column'.format(type_name)
Expand Down Expand Up @@ -596,6 +602,7 @@ def _equal_part(self, other, cache=None):
double = Double()
string = String()
date = Date()
time = Time()
timestamp = Timestamp()


Expand All @@ -611,6 +618,7 @@ def _equal_part(self, other, cache=None):
'double': double,
'string': string,
'date': date,
'time': time,
'timestamp': timestamp
}

Expand Down Expand Up @@ -639,6 +647,7 @@ class Tokens(object):
RBRACKET = 16
TIMEZONE = 17
TIMESTAMP = 18
TIME = 19

@staticmethod
def name(value):
Expand Down Expand Up @@ -669,13 +678,19 @@ def name(value):
'(?P<{}>{})'.format(token.upper(), token),
lambda token, value=value: Token(Tokens.PRIMITIVE, value)
) for token, value in _primitive_types.items()
if token not in {'any', 'null', 'timestamp'}
if token not in {'any', 'null', 'timestamp', 'time'}
] + [
# timestamp
(
r'(?P<TIMESTAMP>timestamp)',
lambda token: Token(Tokens.TIMESTAMP, token),
),
] + [
# time
(
r'(?P<TIME>time)',
lambda token: Token(Tokens.TIME, token),
),
] + [
# decimal + complex types
(
Expand Down Expand Up @@ -814,6 +829,7 @@ def type(self):
| "float"
| "double"
| "string"
| "time"
| timestamp
timestamp : "timestamp"
Expand Down Expand Up @@ -843,6 +859,9 @@ def type(self):
return Timestamp(timezone=timezone)
return timestamp

elif self._accept(Tokens.TIME):
return Time()

elif self._accept(Tokens.DECIMAL):
if self._accept(Tokens.LPAREN):

Expand Down
9 changes: 9 additions & 0 deletions ibis/expr/operations.py
Expand Up @@ -2119,6 +2119,10 @@ def _assert_can_compare(self):
raise TypeError('Arguments are not comparable')


class BetweenTime(Between):
pass


class Contains(BooleanValueOp):

def __init__(self, value, options):
Expand Down Expand Up @@ -2363,6 +2367,11 @@ class ExtractMillisecond(ExtractTimestampField):
pass


class Time(UnaryOp):

output_type = rules.shape_like_arg(0, 'time')


class TimestampFromUNIX(ValueOp):

input_type = [value, rules.string_options(['s', 'ms', 'us'], name='unit')]
Expand Down
6 changes: 5 additions & 1 deletion ibis/expr/rules.py
Expand Up @@ -628,6 +628,10 @@ def date(**arg_kwds):
return ValueTyped(ir.DateValue, 'not date', **arg_kwds)


def time(**arg_kwds):
return ValueTyped(ir.TimeValue, 'not time', **arg_kwds)


def timedelta(**arg_kwds):
from ibis.expr.temporal import Timedelta
return AnyTyped(Timedelta, 'not a timedelta', **arg_kwds)
Expand All @@ -653,7 +657,7 @@ def one_of(args, **arg_kwds):
return OneOf(args, **arg_kwds)


temporal = one_of((dt.timestamp, dt.date))
temporal = one_of((dt.timestamp, dt.date, dt.time))


def instance_of(type_, **arg_kwds):
Expand Down
9 changes: 9 additions & 0 deletions ibis/expr/tests/test_datatypes.py
Expand Up @@ -326,3 +326,12 @@ def test_timestamp_with_timezone_repr():
def test_timestamp_with_timezone_str():
ts = dt.Timestamp('UTC')
assert str(ts) == "timestamp('UTC')"


def test_time():
ts = dt.time
assert str(ts) == "time"


def test_time_valid():
assert dt.validate_type('time').equals(dt.time)
62 changes: 61 additions & 1 deletion ibis/expr/tests/test_value_exprs.py
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

import operator
from datetime import date, datetime
from datetime import date, datetime, time

import pytest

Expand All @@ -23,6 +23,7 @@
import ibis.expr.types as ir
import ibis.expr.operations as ops
import ibis
from ibis.compat import PY2

from ibis import literal
from ibis.tests.util import assert_equal
Expand Down Expand Up @@ -902,3 +903,62 @@ def test_decimal_modulo_output_type(value, type, expected_type_class):
t = ibis.table([('a', type)])
expr = t.a % value
assert isinstance(expr.type(), expected_type_class)


@pytest.mark.parametrize(
('left', 'right'),
[
(literal('10:00'), time(10, 0)),
(time(10, 0), literal('10:00')),
]
)
@pytest.mark.parametrize(
'op',
[
operator.eq,
operator.ne,
operator.lt,
operator.le,
operator.gt,
operator.ge,
lambda left, right: ibis.time(
'10:00'
).between(left, right),
]
)
@pytest.mark.skipif(PY2, reason="time comparsions not available on PY2")
def test_time_compare(op, left, right):
result = op(left, right)
assert result.type().equals(dt.boolean)


@pytest.mark.parametrize(
('left', 'right'),
[
(literal('10:00'), date(2017, 4, 2)),
(literal('10:00'), datetime(2017, 4, 2, 1, 1)),
(literal('10:00'), literal('2017-04-01')),
]
)
@pytest.mark.parametrize(
'op',
[
operator.eq,
operator.lt,
operator.le,
operator.gt,
operator.ge,
]
)
def test_time_timestamp_invalid_compare(op, left, right):
result = op(left, right)
assert result.type().equals(dt.boolean)


@pytest.mark.skipif(not PY2, reason="invalid compare of time on PY2")
def test_time_invalid_compare_on_py2():

# we cannot actually compare datetime.time objects and literals
# in a deferred way in python 2, they short circuit in the CPython
result = operator.eq(time(10, 0), literal('10:00'))
assert not result

0 comments on commit 4990b56

Please sign in to comment.