Skip to content

Commit

Permalink
Makes the Timestamp constructor comply with the contract stated in it…
Browse files Browse the repository at this point in the history
…s docstring; simplifies Timestamp writing logic where possible.
  • Loading branch information
tgregg committed Oct 11, 2019
1 parent 03e8978 commit dc05bfb
Show file tree
Hide file tree
Showing 9 changed files with 379 additions and 258 deletions.
113 changes: 66 additions & 47 deletions amazon/ion/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from collections import MutableMapping, MutableSequence
from datetime import datetime, timedelta, tzinfo
from decimal import Decimal, ROUND_FLOOR
from decimal import Decimal, ROUND_FLOOR, Context, Inexact
from math import isnan

import six
Expand Down Expand Up @@ -375,16 +375,20 @@ def includes_second(self):
TIMESTAMP_PRECISION_FIELD = 'precision'
TIMESTAMP_FRACTION_PRECISION_FIELD = 'fractional_precision'
TIMESTAMP_FRACTIONAL_SECONDS_FIELD = 'fractional_seconds'
TIMESTAMP_MICROSECOND_FIELD = 'microsecond'
MICROSECOND_PRECISION = 6
BASE_TEN_MICROSECOND_PRECISION_EXPONENTIATION = 10 ** MICROSECOND_PRECISION
PRECISION_LIMIT_LOOKUP = {
1: Decimal('0.' + '0' * (1 - 1) + '1'),
2: Decimal('0.' + '0' * (2 - 1) + '1'),
3: Decimal('0.' + '0' * (3 - 1) + '1'),
4: Decimal('0.' + '0' * (4 - 1) + '1'),
5: Decimal('0.' + '0' * (5 - 1) + '1'),
6: Decimal('0.' + '0' * (6 - 1) + '1')
}
DECIMAL_ZERO = Decimal(0)
PRECISION_LIMIT_LOOKUP = (
DECIMAL_ZERO,
Decimal('0.1'),
Decimal('0.01'),
Decimal('0.001'),
Decimal('0.0001'),
Decimal('0.00001'),
Decimal('0.000001')
)
DATETIME_CONSTRUCTOR_MICROSECOND_ARGUMENT_INDEX = 6


class Timestamp(datetime):
Expand All @@ -408,13 +412,15 @@ class Timestamp(datetime):
It must be a :class:`Decimal` in the left-closed, right-opened interval of ``[0, 1)``.
If specified as an argument, ``microseconds`` must be ``None`` **and** ``fractional_precision``
must not be specified (but can be ``None``). In addition, if ``microseconds`` is specified
this argument must not be specified (but can be ``None``).
this argument must not be specified (but can be ``None``). If the specified value has
``coefficient==0`` and ``exponent >= 0``, e.g. ``Decimal(0)``, then there is no precision
beyond seconds.
* If ``microseconds``, ``fractional_precision``, or ``fractional_seconds`` is specified
in the constructor, all fields will be present and normalized in the resulting
:class:`Timestamp` instance. If the precision of ``fractional_seconds`` is more than
is capable of being expressed in ``microseconds``, then the ``microseconds`` field
is truncated to six digits and ``fractional_precision`` is ``6``.
* After construction, ``microseconds``, ``fractional_precision``, and ``fractional_seconds``
will all be present and normalized in the resulting :class:`Timestamp` instance. If the
precision of ``fractional_seconds`` is more than is capable of being expressed in
``microseconds``, then the ``microseconds`` field is truncated to six digits and
``fractional_precision`` is ``6``.
Consider some examples:
Expand All @@ -432,29 +438,40 @@ class Timestamp(datetime):
__slots__ = [TIMESTAMP_PRECISION_FIELD, TIMESTAMP_FRACTION_PRECISION_FIELD, TIMESTAMP_FRACTIONAL_SECONDS_FIELD]

def __new__(cls, *args, **kwargs):
def replace_microsecond(new_value):
if has_microsecond_argument:
lst = list(args)
lst[DATETIME_CONSTRUCTOR_MICROSECOND_ARGUMENT_INDEX] = new_value
return tuple(lst)
else:
kwargs[TIMESTAMP_MICROSECOND_FIELD] = new_value
return args

precision = None
fractional_precision = None
fractional_seconds = None
microsecond_argument_index = 6
datetime_microseconds = None
if len(args) > 6:
datetime_microseconds = args[microsecond_argument_index]
has_microsecond_argument = len(args) > DATETIME_CONSTRUCTOR_MICROSECOND_ARGUMENT_INDEX
if has_microsecond_argument:
datetime_microseconds = args[DATETIME_CONSTRUCTOR_MICROSECOND_ARGUMENT_INDEX]
elif TIMESTAMP_MICROSECOND_FIELD in kwargs:
datetime_microseconds = kwargs.get(TIMESTAMP_MICROSECOND_FIELD)
if TIMESTAMP_PRECISION_FIELD in kwargs:
precision = kwargs.get(TIMESTAMP_PRECISION_FIELD)
# Make sure we mask this before we construct the datetime.
del kwargs[TIMESTAMP_PRECISION_FIELD]
if TIMESTAMP_FRACTION_PRECISION_FIELD in kwargs:
fractional_precision = kwargs.get(TIMESTAMP_FRACTION_PRECISION_FIELD)
if fractional_precision is not None and 1 > fractional_precision > MICROSECOND_PRECISION:
if fractional_precision is not None and not (0 <= fractional_precision <= MICROSECOND_PRECISION):
raise ValueError('Cannot construct a Timestamp with fractional precision of %d digits, '
'which is out of the supported range of [1, %d].'
'which is out of the supported range of [0, %d].'
% (fractional_precision, MICROSECOND_PRECISION))
# Make sure we mask this before we construct the datetime.
del kwargs[TIMESTAMP_FRACTION_PRECISION_FIELD]
if TIMESTAMP_FRACTIONAL_SECONDS_FIELD in kwargs:
fractional_seconds = kwargs.get(TIMESTAMP_FRACTIONAL_SECONDS_FIELD)
if fractional_seconds is not None:
if 0 > fractional_seconds >= 1:
if not (0 <= fractional_seconds < 1):
raise ValueError('Cannot construct a Timestamp with fractional seconds of %s, '
'which is out of the supported range of [0, 1).'
% str(fractional_seconds))
Expand All @@ -468,28 +485,38 @@ def __new__(cls, *args, **kwargs):
if fractional_precision is not None and datetime_microseconds is None:
raise ValueError('datetime_microseconds cannot be None while fractional_precision is not None.')

if fractional_precision is not None and fractional_precision == 0 and datetime_microseconds != 0:
if fractional_precision == 0 and datetime_microseconds != 0:
raise ValueError('datetime_microseconds cannot be non-zero while fractional_precision is 0.')

if fractional_precision is not None and fractional_precision == 0 and datetime_microseconds == 0:
fractional_seconds = Decimal('0e0')
elif fractional_seconds is not None:
fractional_precision = min(fractional_seconds.as_tuple().exponent * -1, MICROSECOND_PRECISION)
if fractional_seconds is not None:
fractional_seconds_exponent = fractional_seconds.as_tuple().exponent
if fractional_seconds == DECIMAL_ZERO and fractional_seconds_exponent > 0:
# Zero with a positive exponent is just zero. Set the exponent to zero so fractional_precision is
# calculated correctly.
fractional_seconds_exponent = 0
fractional_seconds = DECIMAL_ZERO
fractional_precision = min(-fractional_seconds_exponent, MICROSECOND_PRECISION)
# Scale to microseconds and truncate to an integer.
datetime_microseconds = int(fractional_seconds * BASE_TEN_MICROSECOND_PRECISION_EXPONENTIATION)
lst = list(args)
lst[microsecond_argument_index] = datetime_microseconds
args = tuple(lst)
elif not (datetime_microseconds == 0 and fractional_precision is None):
if datetime_microseconds is None:
if len(args) > 6:
datetime_microseconds = 0
lst = list(args)
lst[microsecond_argument_index] = datetime_microseconds
args = tuple(lst)
args = replace_microsecond(int(fractional_seconds * BASE_TEN_MICROSECOND_PRECISION_EXPONENTIATION))
elif datetime_microseconds is not None:
if fractional_precision is None:
fractional_precision = MICROSECOND_PRECISION
if fractional_precision == 0:
# As previously verified, datetime_microseconds must be zero in this case.
fractional_seconds = DECIMAL_ZERO
else:
fractional_seconds = Decimal(datetime_microseconds).scaleb(-MICROSECOND_PRECISION)\
.quantize(PRECISION_LIMIT_LOOKUP[fractional_precision], rounding=ROUND_FLOOR)
try:
fractional_seconds = Decimal(datetime_microseconds).scaleb(-MICROSECOND_PRECISION)\
.quantize(PRECISION_LIMIT_LOOKUP[fractional_precision], context=Context(traps=[Inexact]))
except Inexact:
raise ValueError('microsecond value %d cannot be expressed exactly in %d digits.'
% (datetime_microseconds, fractional_precision))
else:
assert datetime_microseconds is None
# The datetime constructor requires a non-None microsecond argument.
args = replace_microsecond(0)
fractional_precision = 0
fractional_seconds = DECIMAL_ZERO

instance = super(Timestamp, cls).__new__(cls, *args, **kwargs)
setattr(instance, TIMESTAMP_PRECISION_FIELD, precision)
Expand Down Expand Up @@ -561,14 +588,6 @@ def timestamp(year, month=1, day=1,
if delta is not None:
tz = OffsetTZInfo(delta)

if microsecond is not None:
if fractional_precision is None:
fractional_precision = MICROSECOND_PRECISION
elif fractional_seconds is None:
microsecond = 0
if fractional_precision is not None:
raise ValueError('Cannot have fractional precision without a fractional component.')

return Timestamp(
year, month, day,
hour, minute, second, microsecond,
Expand Down
3 changes: 0 additions & 3 deletions amazon/ion/reader_binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,6 @@ def parse_timestamp():
fraction = None
else:
fraction = _parse_decimal(buf)
if fraction < 0 or fraction >= 1:
raise IonException(
'Timestamp has a fractional component out of bounds: %s' % fraction)
fraction_exponent = fraction.as_tuple().exponent
if fraction == 0 and fraction_exponent > -1:
# According to the spec, fractions with coefficients of zero and exponents >= zero are ignored.
Expand Down
1 change: 0 additions & 1 deletion amazon/ion/reader_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,6 @@ def parse():
off_hour = tokens[_TimestampState.OFF_HOUR]
off_minutes = tokens[_TimestampState.OFF_MINUTE]
fraction = None
fraction_digits = None
if off_hour is not None:
assert off_minutes is not None
off_sign = -1 if _MINUS in off_hour else 1
Expand Down
16 changes: 5 additions & 11 deletions amazon/ion/simple_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,14 @@ def __init__(self, *args, **kwargs):

@staticmethod
def _to_constructor_args(ts):
try:
fractional_precision = getattr(ts, TIMESTAMP_FRACTION_PRECISION_FIELD)
except AttributeError:
fractional_precision = MICROSECOND_PRECISION
fractional_seconds = getattr(ts, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, None)
precision = getattr(ts, TIMESTAMP_PRECISION_FIELD, TimestampPrecision.SECOND)
if fractional_seconds is not None:
if isinstance(ts, Timestamp):
args = (ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second, None, ts.tzinfo)
fractional_precision = None
fractional_seconds = getattr(ts, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, None)
precision = getattr(ts, TIMESTAMP_PRECISION_FIELD, TimestampPrecision.SECOND)
kwargs = {TIMESTAMP_PRECISION_FIELD: precision, TIMESTAMP_FRACTIONAL_SECONDS_FIELD: fractional_seconds}
else:
args = (ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second, ts.microsecond, ts.tzinfo)
kwargs = {TIMESTAMP_PRECISION_FIELD: precision, TIMESTAMP_FRACTION_PRECISION_FIELD: fractional_precision,
TIMESTAMP_FRACTIONAL_SECONDS_FIELD: fractional_seconds}

kwargs = {TIMESTAMP_PRECISION_FIELD: TimestampPrecision.SECOND}
return args, kwargs


Expand Down
54 changes: 14 additions & 40 deletions amazon/ion/writer_binary_raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from amazon.ion.equivalence import _is_float_negative_zero
from amazon.ion.symbols import SymbolToken
from .core import IonEventType, IonType, DataEvent, Transition, TimestampPrecision, TIMESTAMP_FRACTION_PRECISION_FIELD, \
MICROSECOND_PRECISION, TIMESTAMP_PRECISION_FIELD, TIMESTAMP_FRACTIONAL_SECONDS_FIELD
MICROSECOND_PRECISION, TIMESTAMP_PRECISION_FIELD, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, Timestamp
from .util import coroutine, total_seconds, Enum
from .writer import NOOP_WRITER_EVENT, WriteEventType, \
writer_trampoline, partial_transition, serialize_scalar, \
Expand Down Expand Up @@ -170,10 +170,13 @@ def _write_decimal_value(buf, exponent, coefficient, sign=0):
return length


def _write_decimal_to_buf(buf, value):
def _write_timestamp_fractional_seconds(buf, value):
sign, digits, exponent = value.as_tuple()
coefficient = int(value.scaleb(-exponent).to_integral_value())
length = _write_decimal_value(buf, exponent, coefficient, sign)
if coefficient == 0 and exponent >= 0:
length = 0
else:
length = _write_decimal_value(buf, exponent, coefficient, sign)
return length


Expand Down Expand Up @@ -231,19 +234,6 @@ def _serialize_lob_value(event, tid):
_serialize_clob = partial(_serialize_lob_value, tid=_TypeIds.CLOB)


_MICROSECOND_DECIMAL_EXPONENT = -6 # There are 1e6 microseconds per second.

_TEN_EXP_MINUS_ONE = (
-1,
9,
99,
999,
9999,
99999,
999999,
)


def _serialize_timestamp(ion_event):
buf = bytearray()
dt = ion_event.value
Expand All @@ -270,30 +260,14 @@ def _serialize_timestamp(ion_event):
length += _write_varuint(value_buf, dt.minute)
if precision.includes_second:
length += _write_varuint(value_buf, dt.second)
coefficient_fraction_seconds = getattr(ion_event.value, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, None)
fractional_precision = getattr(ion_event.value, TIMESTAMP_FRACTION_PRECISION_FIELD, MICROSECOND_PRECISION)
coefficient = dt.microsecond
if coefficient is not None and fractional_precision is not None:
if coefficient == 0:
adjusted_fractional_precision = fractional_precision
else:
adjusted_fractional_precision = MICROSECOND_PRECISION
# This optimizes the size of the fractional encoding when the extra precision is not needed.
while adjusted_fractional_precision > fractional_precision and coefficient % 10 == 0:
coefficient //= 10
adjusted_fractional_precision -= 1
if adjusted_fractional_precision > fractional_precision or \
coefficient > _TEN_EXP_MINUS_ONE[fractional_precision]:
raise ValueError('Error writing event %s. Found timestamp fractional precision of %d digits, '
'which is less than needed to serialize %d microseconds.'
% (ion_event, fractional_precision, dt.microsecond))
if coefficient_fraction_seconds is None:
exponent = -adjusted_fractional_precision
if not (coefficient == 0 and exponent >= 0):
length += _write_decimal_value(value_buf, exponent, coefficient)

if coefficient_fraction_seconds is not None:
length += _write_decimal_to_buf(value_buf, coefficient_fraction_seconds)
if isinstance(ion_event.value, Timestamp):
fractional_seconds = getattr(ion_event.value, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, None)
if fractional_seconds is not None:
length += _write_timestamp_fractional_seconds(value_buf, fractional_seconds)
else:
# This must be a normal datetime, which always has a range-validated microsecond value.
length += _write_decimal_value(value_buf, -MICROSECOND_PRECISION, dt.microsecond)

_write_length(buf, length, _TypeIds.TIMESTAMP)
buf.extend(value_buf)
return buf
Expand Down
34 changes: 14 additions & 20 deletions amazon/ion/writer_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@

from .util import coroutine, unicode_iter
from .core import DataEvent, Transition, IonEventType, IonType, TIMESTAMP_PRECISION_FIELD, TimestampPrecision, \
_ZERO_DELTA, TIMESTAMP_FRACTION_PRECISION_FIELD, MICROSECOND_PRECISION, TIMESTAMP_FRACTIONAL_SECONDS_FIELD
_ZERO_DELTA, TIMESTAMP_FRACTION_PRECISION_FIELD, MICROSECOND_PRECISION, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, \
Timestamp, DECIMAL_ZERO
from .writer import partial_transition, writer_trampoline, serialize_scalar, validate_scalar_value, \
illegal_state_null, NOOP_WRITER_EVENT
from .writer import WriteEventType
Expand Down Expand Up @@ -172,26 +173,19 @@ def _bytes_datetime(dt):
else:
return tz_string + _bytes_utc_offset(dt)

fractional_seconds = getattr(original_dt, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, None)
if fractional_seconds is None:
fractional_precision = getattr(original_dt, TIMESTAMP_FRACTION_PRECISION_FIELD, MICROSECOND_PRECISION)
fractional = dt.strftime('%f')
assert len(fractional) == MICROSECOND_PRECISION

if fractional_precision is not None:
if fractional[fractional_precision:] != ('0' * (MICROSECOND_PRECISION - fractional_precision)):
raise ValueError('Found timestamp fractional with more than the specified %d digits of precision.'
% (fractional_precision,))
fractional = fractional[:fractional_precision]
tz_string += '.' + fractional

if isinstance(original_dt, Timestamp):
fractional_seconds = getattr(original_dt, TIMESTAMP_FRACTIONAL_SECONDS_FIELD, None)
if fractional_seconds is not None:
_, digits, exponent = fractional_seconds.as_tuple()
if not (fractional_seconds == DECIMAL_ZERO and exponent >= 0):
leading_zeroes = -exponent - len(digits)
tz_string += '.'
if leading_zeroes > 0:
tz_string += '0' * leading_zeroes
tz_string += ''.join(str(x) for x in digits)
else:
_, digits, exponent = fractional_seconds.as_tuple()
leading_zeroes = -exponent - len(digits)
tz_string += '.'
if leading_zeroes > 0:
tz_string += '0' * leading_zeroes
tz_string += ''.join(str(x) for x in digits)
# This must be a normal datetime, which always has a range-validated microsecond value.
tz_string += '.' + dt.strftime('%f')
return tz_string + _bytes_utc_offset(dt)


Expand Down

0 comments on commit dc05bfb

Please sign in to comment.