Skip to content

Commit

Permalink
api: pandas way to build datetime from timestamp
Browse files Browse the repository at this point in the history
This option is required so it would be possible to decode Datetime with
external function without constructing excessive pandas.Timestamp
object.

Follows #204
  • Loading branch information
DifferentialOrange committed Oct 27, 2022
1 parent fe6f9b1 commit b338a1f
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 21 deletions.
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Support iproto feature discovery (#206).

- Support pandas way to build datetime from timestamp (PR #252).

`timestamp_since_utc_epoch` is a parameter to set timestamp
convertion behavior for timezone-aware datetimes.

If ``False`` (default), behaves similar to Tarantool `datetime.new()`:

```python
>>> dt = tarantool.Datetime(timestamp=1640995200, timestamp_since_utc_epoch=False)
>>> dt
datetime: Timestamp('2022-01-01 00:00:00'), tz: ""
>>> dt.timestamp
1640995200.0
>>> dt = tarantool.Datetime(timestamp=1640995200, tz='Europe/Moscow',
... timestamp_since_utc_epoch=False)
>>> dt
datetime: Timestamp('2022-01-01 00:00:00+0300', tz='Europe/Moscow'), tz: "Europe/Moscow"
>>> dt.timestamp
1640984400.0
```

Thus, if ``False``, datetime is computed from timestamp
since epoch and then timezone is applied without any
convertion. In that case, `dt.timestamp` won't be equal to
initialization `timestamp` for all timezones with non-zero offset.

If ``True``, behaves similar to `pandas.Timestamp`:

```python
>>> dt = tarantool.Datetime(timestamp=1640995200, timestamp_since_utc_epoch=True)
>>> dt
datetime: Timestamp('2022-01-01 00:00:00'), tz: ""
>>> dt.timestamp
1640995200.0
>>> dt = tarantool.Datetime(timestamp=1640995200, tz='Europe/Moscow',
... timestamp_since_utc_epoch=True)
>>> dt
datetime: Timestamp('2022-01-01 03:00:00+0300', tz='Europe/Moscow'), tz: "Europe/Moscow"
>>> dt.timestamp
1640995200.0
```

Thus, if ``True``, datetime is computed in a way that `dt.timestamp` will
always be equal to initialization `timestamp`.

### Changed
- Bump msgpack requirement to 1.0.4 (PR #223).
The only reason of this bump is various vulnerability fixes,
Expand Down
98 changes: 77 additions & 21 deletions tarantool/msgpack_ext/types/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ class Datetime():

def __init__(self, data=None, *, timestamp=None, year=None, month=None,
day=None, hour=None, minute=None, sec=None, nsec=None,
tzoffset=0, tz=''):
tzoffset=0, tz='', timestamp_since_utc_epoch=False):
"""
:param data: MessagePack binary data to decode. If provided,
all other parameters are ignored.
Expand All @@ -294,7 +294,10 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None,
:paramref:`~tarantool.Datetime.params.minute`,
:paramref:`~tarantool.Datetime.params.sec`.
If :paramref:`~tarantool.Datetime.params.nsec` is provided,
it must be :obj:`int`.
it must be :obj:`int`. Refer to
:paramref:`~tarantool.Datetime.params.timestamp_since_utc_epoch`
to clarify how timezone-aware datetime is computed from
the timestamp.
:type timestamp: :obj:`float` or :obj:`int`, optional
:param year: Datetime year value. Must be a valid
Expand Down Expand Up @@ -344,8 +347,60 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None,
:param tz: Timezone name from Olson timezone database.
:type tz: :obj:`str`, optional
:param timestamp_since_utc_epoch: Parameter to set timestamp
convertion behavior for timezone-aware datetimes.
If ``False`` (default), behaves similar to Tarantool
`datetime.new()`_:
.. code-block:: python
>>> dt = tarantool.Datetime(timestamp=1640995200, timestamp_since_utc_epoch=False)
>>> dt
datetime: Timestamp('2022-01-01 00:00:00'), tz: ""
>>> dt.timestamp
1640995200.0
>>> dt = tarantool.Datetime(timestamp=1640995200, tz='Europe/Moscow',
... timestamp_since_utc_epoch=False)
>>> dt
datetime: Timestamp('2022-01-01 00:00:00+0300', tz='Europe/Moscow'), tz: "Europe/Moscow"
>>> dt.timestamp
1640984400.0
Thus, if ``False``, datetime is computed from timestamp
since epoch and then timezone is applied without any
convertion. In that case,
:attr:`~tarantool.Datetime.timestamp` won't be equal to
initialization
:paramref:`~tarantool.Datetime.params.timestamp` for all
timezones with non-zero offset.
If ``True``, behaves similar to :class:`pandas.Timestamp`:
.. code-block:: python
>>> dt = tarantool.Datetime(timestamp=1640995200, timestamp_since_utc_epoch=True)
>>> dt
datetime: Timestamp('2022-01-01 00:00:00'), tz: ""
>>> dt.timestamp
1640995200.0
>>> dt = tarantool.Datetime(timestamp=1640995200, tz='Europe/Moscow',
... timestamp_since_utc_epoch=True)
>>> dt
datetime: Timestamp('2022-01-01 03:00:00+0300', tz='Europe/Moscow'), tz: "Europe/Moscow"
>>> dt.timestamp
1640995200.0
Thus, if ``True``, datetime is computed in a way that
:attr:`~tarantool.Datetime.timestamp` will always be equal
to initialization
:paramref:`~tarantool.Datetime.params.timestamp`.
:type timestamp_since_utc_epoch: :obj:`bool`, optional
:raise: :exc:`ValueError`, :exc:`~tarantool.error.MsgpackError`,
:class:`pandas.Timestamp` exceptions
.. _datetime.new(): https://www.tarantool.io/en/doc/latest/reference/reference_lua/datetime/new/
"""

if data is not None:
Expand All @@ -358,6 +413,16 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None,
self._tz = tz
return

tzinfo = None
if tz != '':
if tz not in tt_timezones.timezoneToIndex:
raise ValueError(f'Unknown Tarantool timezone "{tz}"')

tzinfo = get_python_tzinfo(tz, ValueError)
elif tzoffset != 0:
tzinfo = pytz.FixedOffset(tzoffset)
self._tz = tz

# The logic is same as in Tarantool, refer to datetime API.
# https://www.tarantool.io/en/doc/latest/reference/reference_lua/datetime/new/
if timestamp is not None:
Expand All @@ -375,6 +440,11 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None,
datetime = pandas.to_datetime(total_nsec, unit='ns')
else:
datetime = pandas.to_datetime(timestamp, unit='s')

if not timestamp_since_utc_epoch:
self._datetime = datetime.replace(tzinfo=tzinfo)
else:
self._datetime = datetime.replace(tzinfo=pytz.UTC).tz_convert(tzinfo)
else:
if nsec is not None:
microsecond = nsec // NSEC_IN_MKSEC
Expand All @@ -383,25 +453,11 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None,
microsecond = 0
nanosecond = 0

datetime = pandas.Timestamp(year=year, month=month, day=day,
hour=hour, minute=minute, second=sec,
microsecond=microsecond,
nanosecond=nanosecond)

if tz != '':
if tz not in tt_timezones.timezoneToIndex:
raise ValueError(f'Unknown Tarantool timezone "{tz}"')

tzinfo = get_python_tzinfo(tz, ValueError)
self._datetime = datetime.replace(tzinfo=tzinfo)
self._tz = tz
elif tzoffset != 0:
tzinfo = pytz.FixedOffset(tzoffset)
self._datetime = datetime.replace(tzinfo=tzinfo)
self._tz = ''
else:
self._datetime = datetime
self._tz = ''
self._datetime = pandas.Timestamp(
year=year, month=month, day=day,
hour=hour, minute=minute, second=sec,
microsecond=microsecond,
nanosecond=nanosecond, tzinfo=tzinfo)

def _interval_operation(self, other, sign=1):
"""
Expand Down
6 changes: 6 additions & 0 deletions test/suites/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,12 @@ def test_Datetime_class_invalid_init(self):
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
r"nsec=308543321, tz='AZODT'})",
},
'timestamp_since_utc_epoch': {
'python': tarantool.Datetime(timestamp=1661958474, nsec=308543321,
tz='Europe/Moscow', timestamp_since_utc_epoch=True),
'msgpack': (b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\xb3\x03'),
'tarantool': r"datetime.new({timestamp=1661969274, nsec=308543321, tz='Europe/Moscow'})",
},
}

def test_msgpack_decode(self):
Expand Down

0 comments on commit b338a1f

Please sign in to comment.