Skip to content

Commit 7d30db8

Browse files
package: allow to use without pandas and pytz
pandas and pytz packages are required to build a tarantool.Datetime object. If user doesn't plan to work with datetimes, they still would be installed. This patch makes this code dependency optional. If packages are not provided, when Tarantool sends a Datetime object, its encoded bytes data would be simply put to a `tarantool.DatetimeRaw` object. Part of #290
1 parent 4bdbdda commit 7d30db8

File tree

7 files changed

+130
-12
lines changed

7 files changed

+130
-12
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Migrate to built-in `Warning` instead of a custom one.
1212
- Migrate to built-in `RecursionError` instead of a custom one.
1313
- Collect full exception traceback.
14+
- Allow to use connector without `pandas` and `pytz`. In this case,
15+
when Tarantool sends a Datetime object, its encoded bytes data would be simply put
16+
to a `tarantool.DatetimeRaw` object (#290).
1417

1518
## 0.12.1 - 2023-02-28
1619

tarantool/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
from tarantool.msgpack_ext.types.datetime import (
3636
Datetime,
37+
DatetimeRaw,
3738
)
3839

3940
from tarantool.msgpack_ext.types.interval import (
@@ -141,5 +142,5 @@ def connectmesh(addrs=({'host': 'localhost', 'port': 3301},), user=None,
141142

142143
__all__ = ['connect', 'Connection', 'connectmesh', 'MeshConnection', 'Schema',
143144
'Error', 'DatabaseError', 'NetworkError', 'NetworkWarning',
144-
'SchemaError', 'dbapi', 'Datetime', 'Interval', 'IntervalAdjust',
145-
'ConnectionPool', 'Mode', 'BoxError']
145+
'SchemaError', 'dbapi', 'Datetime', 'DatetimeRaw', 'Interval',
146+
'IntervalAdjust', 'ConnectionPool', 'Mode', 'BoxError']

tarantool/msgpack_ext/datetime.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@
3939
"""
4040

4141
from tarantool.msgpack_ext.types.datetime import (
42+
IS_DATETIME_SUPPORTED,
4243
NSEC_IN_SEC,
4344
Datetime,
45+
DatetimeRaw,
4446
)
4547
import tarantool.msgpack_ext.types.timezones as tt_timezones
4648

@@ -83,14 +85,17 @@ def encode(obj, _):
8385
Encode a datetime object.
8486
8587
:param obj: Datetime to encode.
86-
:type: :obj: :class:`tarantool.Datetime`
88+
:type obj: :class:`tarantool.Datetime`, :class:`tarantool.DatetimeRaw`
8789
8890
:return: Encoded datetime.
8991
:rtype: :obj:`bytes`
9092
9193
:raise: :exc:`tarantool.Datetime.msgpack_encode` exceptions
9294
"""
9395

96+
if isinstance(obj, DatetimeRaw):
97+
return obj.data
98+
9499
seconds = obj.value // NSEC_IN_SEC
95100
nsec = obj.nsec
96101
tzoffset = obj.tzoffset
@@ -142,13 +147,17 @@ def decode(data, _):
142147
:param obj: Datetime to decode.
143148
:type obj: :obj:`bytes`
144149
145-
:return: Decoded datetime.
146-
:rtype: :class:`tarantool.Datetime`
150+
:return: Decoded datetime. If :class:`tarantool.Datetime` is not
151+
supported, :class:`tarantool.DatetimeRaw` returned instead.
152+
:rtype: :class:`tarantool.Datetime`, :class:`tarantool.DatetimeRaw`
147153
148154
:raise: :exc:`~tarantool.error.MsgpackError`,
149155
:exc:`tarantool.Datetime` exceptions
150156
"""
151157

158+
if not IS_DATETIME_SUPPORTED:
159+
return DatetimeRaw(data)
160+
152161
cursor = 0
153162
seconds, cursor = get_bytes_as_int(data, cursor, SECONDS_SIZE_BYTES)
154163

tarantool/msgpack_ext/packer.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from msgpack import ExtType
1111

1212
from tarantool.types import BoxError
13-
from tarantool.msgpack_ext.types.datetime import Datetime
13+
from tarantool.msgpack_ext.types.datetime import Datetime, DatetimeRaw
1414
from tarantool.msgpack_ext.types.interval import Interval
1515

1616
import tarantool.msgpack_ext.decimal as ext_decimal
@@ -24,6 +24,7 @@
2424
{'type': UUID, 'ext': ext_uuid},
2525
{'type': BoxError, 'ext': ext_error},
2626
{'type': Datetime, 'ext': ext_datetime},
27+
{'type': DatetimeRaw, 'ext': ext_datetime},
2728
{'type': Interval, 'ext': ext_interval},
2829
]
2930

@@ -35,7 +36,7 @@ def default(obj, packer=None):
3536
:param obj: Object to encode.
3637
:type obj: :class:`decimal.Decimal` or :class:`uuid.UUID` or
3738
or :class:`tarantool.BoxError` or :class:`tarantool.Datetime`
38-
or :class:`tarantool.Interval`
39+
or :class:`tarantool.DatetimeRaw` or :class:`tarantool.Interval`
3940
4041
:param packer: msgpack packer to work with common types
4142
(like dictionary in extended error payload)

tarantool/msgpack_ext/types/datetime.py

+22-2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
# pylint: disable=line-too-long
55

66
from copy import deepcopy
7+
from dataclasses import dataclass
78

8-
import pandas
9-
import pytz
9+
try:
10+
import pandas
11+
import pytz
12+
IS_DATETIME_SUPPORTED = True
13+
except ImportError:
14+
IS_DATETIME_SUPPORTED = False
1015

1116
import tarantool.msgpack_ext.types.timezones as tt_timezones
1217

@@ -267,6 +272,8 @@ def __init__(self, *, timestamp=None, year=None, month=None,
267272
"""
268273
# pylint: disable=too-many-branches,too-many-locals
269274

275+
assert IS_DATETIME_SUPPORTED, "pandas and pytz are required"
276+
270277
tzinfo = None
271278
if tz != '':
272279
if tz not in tt_timezones.timezoneToIndex:
@@ -638,3 +645,16 @@ def value(self):
638645
"""
639646

640647
return self._datetime.value
648+
649+
650+
@dataclass
651+
class DatetimeRaw():
652+
"""
653+
Class representing Tarantool `datetime`_ info in raw format. Used when
654+
:class:`pandas.Timestamp` or :class:`pytz` are unavailable.
655+
"""
656+
657+
data: bytes
658+
"""
659+
:type: :obj:`bytes`
660+
"""

tarantool/msgpack_ext/unpacker.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ def ext_hook(code, data, unpacker=None):
3434
(like dictionary in extended error payload)
3535
:type unpacker: :class:`msgpack.Unpacker`, optional
3636
37-
:return: Decoded value.
37+
:return: Decoded value. If :class:`tarantool.Datetime` is not
38+
supported, :class:`tarantool.DatetimeRaw` returned instead.
3839
:rtype: :class:`decimal.Decimal` or :class:`uuid.UUID` or
39-
or :class:`tarantool.BoxError` or :class:`tarantool.Datetime`
40-
or :class:`tarantool.Interval`
40+
or :class:`tarantool.BoxError` or :class:`tarantool.Datetime`
41+
or :class:`tarantool.DatetimeRaw` or :class:`tarantool.Interval`
4142
4243
:raise: :exc:`NotImplementedError`
4344
"""

test/suites/test_datetime.py

+83
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313
from tarantool.error import MsgpackError
1414
from tarantool.msgpack_ext.packer import default as packer_default
1515
from tarantool.msgpack_ext.unpacker import ext_hook as unpacker_ext_hook
16+
from tarantool.msgpack_ext.types.datetime import IS_DATETIME_SUPPORTED
1617

1718
from .lib.tarantool_server import TarantoolServer
1819
from .lib.skip import skip_or_run_datetime_test
1920

21+
# Workaround for matrix test cases build.
22+
if not IS_DATETIME_SUPPORTED:
23+
ORIGINAL_DATETIME_IMPL = tarantool.Datetime
24+
tarantool.Datetime = lambda *args, **kwars: None
25+
2026

2127
class TestSuiteDatetime(unittest.TestCase):
2228
@classmethod
@@ -69,6 +75,7 @@ def setUp(self):
6975

7076
self.adm("box.space['test']:truncate()")
7177

78+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
7279
def test_datetime_class_api(self):
7380
datetime = tarantool.Datetime(year=2022, month=8, day=31, hour=18, minute=7, sec=54,
7481
nsec=308543321, tzoffset=180)
@@ -86,6 +93,7 @@ def test_datetime_class_api(self):
8693
self.assertEqual(datetime.tz, '')
8794
self.assertEqual(datetime.value, 1661958474308543321)
8895

96+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
8997
def test_datetime_class_api_wth_tz(self):
9098
datetime = tarantool.Datetime(year=2022, month=8, day=31, hour=18, minute=7, sec=54,
9199
nsec=308543321, tzoffset=123, tz='Europe/Moscow')
@@ -142,6 +150,7 @@ def test_datetime_class_api_wth_tz(self):
142150
},
143151
}
144152

153+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
145154
def test_datetime_class_invalid_init(self):
146155
# pylint: disable=cell-var-from-loop
147156

@@ -282,12 +291,14 @@ def test_datetime_class_invalid_init(self):
282291
},
283292
}
284293

294+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
285295
def test_msgpack_decode(self):
286296
for name, case in self.integration_cases.items():
287297
with self.subTest(msg=name):
288298
self.assertEqual(unpacker_ext_hook(4, case['msgpack']),
289299
case['python'])
290300

301+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
291302
@skip_or_run_datetime_test
292303
def test_tarantool_decode(self):
293304
for name, case in self.integration_cases.items():
@@ -297,12 +308,14 @@ def test_tarantool_decode(self):
297308
self.assertSequenceEqual(self.con.select('test', name),
298309
[[name, case['python'], 'field']])
299310

311+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
300312
def test_msgpack_encode(self):
301313
for name, case in self.integration_cases.items():
302314
with self.subTest(msg=name):
303315
self.assertEqual(packer_default(case['python']),
304316
msgpack.ExtType(code=4, data=case['msgpack']))
305317

318+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
306319
@skip_or_run_datetime_test
307320
def test_tarantool_encode(self):
308321
for name, case in self.integration_cases.items():
@@ -325,12 +338,14 @@ def test_tarantool_encode(self):
325338

326339
self.assertSequenceEqual(self.adm(lua_eval), [True])
327340

341+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
328342
def test_msgpack_decode_unknown_tzindex(self):
329343
case = b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\xff\xff'
330344
self.assertRaisesRegex(
331345
MsgpackError, 'Failed to decode datetime with unknown tzindex "-1"',
332346
lambda: unpacker_ext_hook(4, case))
333347

348+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
334349
def test_msgpack_decode_ambiguous_tzindex(self):
335350
case = b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\x00\x00\x82\x00'
336351
self.assertRaisesRegex(
@@ -364,11 +379,13 @@ def test_msgpack_decode_ambiguous_tzindex(self):
364379
},
365380
}
366381

382+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
367383
def test_python_datetime_subtraction(self):
368384
for name, case in self.datetime_subtraction_cases.items():
369385
with self.subTest(msg=name):
370386
self.assertEqual(case['arg_1'] - case['arg_2'], case['res'])
371387

388+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
372389
@skip_or_run_datetime_test
373390
def test_tarantool_datetime_subtraction(self):
374391
for name, case in self.datetime_subtraction_cases.items():
@@ -382,11 +399,13 @@ def test_tarantool_datetime_subtraction(self):
382399
'res': tarantool.Interval(day=1, hour=-21),
383400
}
384401

402+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
385403
def test_python_datetime_subtraction_different_timezones(self):
386404
case = self.datetime_subtraction_different_timezones_case
387405

388406
self.assertEqual(case['arg_1'] - case['arg_2'], case['res'])
389407

408+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
390409
@skip_or_run_datetime_test
391410
@unittest.skip('See https://github.com/tarantool/tarantool/issues/7698')
392411
def test_tarantool_datetime_subtraction_different_timezones(self):
@@ -478,23 +497,27 @@ def test_tarantool_datetime_subtraction_different_timezones(self):
478497
},
479498
}
480499

500+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
481501
def test_python_interval_addition(self):
482502
for name, case in self.interval_arithmetic_cases.items():
483503
with self.subTest(msg=name):
484504
self.assertEqual(case['arg_1'] + case['arg_2'], case['res_add'])
485505

506+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
486507
def test_python_interval_subtraction(self):
487508
for name, case in self.interval_arithmetic_cases.items():
488509
with self.subTest(msg=name):
489510
self.assertEqual(case['arg_1'] - case['arg_2'], case['res_sub'])
490511

512+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
491513
@skip_or_run_datetime_test
492514
def test_tarantool_interval_addition(self):
493515
for name, case in self.interval_arithmetic_cases.items():
494516
with self.subTest(msg=name):
495517
self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']),
496518
[case['res_add']])
497519

520+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
498521
@skip_or_run_datetime_test
499522
def test_tarantool_interval_subtraction(self):
500523
for name, case in self.interval_arithmetic_cases.items():
@@ -508,11 +531,13 @@ def test_tarantool_interval_subtraction(self):
508531
'res': tarantool.Datetime(year=2008, month=7, day=1, hour=12, tz='Europe/Moscow'),
509532
}
510533

534+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
511535
def test_python_datetime_addition_winter_time_switch(self):
512536
case = self.datetime_addition_winter_time_switch_case
513537

514538
self.assertEqual(case['arg_1'] + case['arg_2'], case['res'])
515539

540+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
516541
@skip_or_run_datetime_test
517542
@unittest.skip('See https://github.com/tarantool/tarantool/issues/7700')
518543
def test_tarantool_datetime_addition_winter_time_switch(self):
@@ -521,13 +546,71 @@ def test_tarantool_datetime_addition_winter_time_switch(self):
521546
self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']),
522547
[case['res']])
523548

549+
@unittest.skipIf(not IS_DATETIME_SUPPORTED, "pandas or pytz not installed")
524550
@skip_or_run_datetime_test
525551
def test_primary_key(self):
526552
data = [tarantool.Datetime(year=1970, month=1, day=1), 'content']
527553

528554
self.assertSequenceEqual(self.con.insert('test_pk', data), [data])
529555
self.assertSequenceEqual(self.con.select('test_pk', data[0]), [data])
530556

557+
integration_raw_case = {
558+
'python': tarantool.DatetimeRaw(b'\x44\xa3\x0f\x63\x00\x00\x00\x00'),
559+
'msgpack': (b'\x44\xa3\x0f\x63\x00\x00\x00\x00'),
560+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7})",
561+
}
562+
563+
@unittest.skipIf(IS_DATETIME_SUPPORTED, "pandas or pytz are installed")
564+
def test_msgpack_decode_raw(self):
565+
case = self.integration_raw_case
566+
self.assertEqual(unpacker_ext_hook(4, case['msgpack']),
567+
case['python'])
568+
569+
@unittest.skipIf(IS_DATETIME_SUPPORTED, "pandas or pytz are installed")
570+
@skip_or_run_datetime_test
571+
def test_tarantool_decode_raw(self):
572+
name = 'decode_raw'
573+
case = self.integration_raw_case
574+
self.adm(f"box.space['test']:replace{{'{name}', {case['tarantool']}, 'field'}}")
575+
576+
self.assertSequenceEqual(self.con.select('test', name),
577+
[[name, case['python'], 'field']])
578+
579+
@unittest.skipIf(IS_DATETIME_SUPPORTED, "pandas or pytz are installed")
580+
def test_msgpack_encode_raw(self):
581+
case = self.integration_raw_case
582+
self.assertEqual(packer_default(case['python']),
583+
msgpack.ExtType(code=4, data=case['msgpack']))
584+
585+
@unittest.skipIf(IS_DATETIME_SUPPORTED, "pandas or pytz are installed")
586+
@skip_or_run_datetime_test
587+
def test_tarantool_encode_raw(self):
588+
name = 'encode_raw'
589+
case = self.integration_raw_case
590+
self.con.insert('test', [name, case['python'], 'field'])
591+
592+
lua_eval = f"""
593+
local dt = {case['tarantool']}
594+
595+
local tuple = box.space['test']:get('{name}')
596+
assert(tuple ~= nil)
597+
598+
if tuple[2] == dt then
599+
return true
600+
else
601+
return nil, ('%s is not equal to expected %s'):format(
602+
tostring(tuple[2]), tostring(dt))
603+
end
604+
"""
605+
606+
self.assertSequenceEqual(self.adm(lua_eval), [True])
607+
608+
@unittest.skipIf(IS_DATETIME_SUPPORTED, "pandas or pytz are installed")
609+
def test_datetime_if_not_supported(self):
610+
self.assertRaisesRegex(
611+
AssertionError, "pandas and pytz are required",
612+
lambda: ORIGINAL_DATETIME_IMPL(year=1970, month=1, day=1))
613+
531614
@classmethod
532615
def tearDownClass(cls):
533616
cls.con.close()

0 commit comments

Comments
 (0)