From eef53417607cd7e2de1da3d78950fa209eb26199 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Thu, 22 Dec 2022 12:40:07 +0100 Subject: [PATCH 1/7] Accept more types as query parameters Implement serialization support for * python types: tuple * pandas types: * all but period, interval, pyarrow * numpy types: * all but void, complexfloating pyarrow support was not implemented as it would either require more ifs in the recursive packing function, making it (driver's hot-path) slower for non-pyarrow use-cases. Or alternatively, transformers would have to be used making pyarrow type serialization rather slow. --- docs/source/_images/core_type_mappings.png | Bin 61710 -> 0 bytes docs/source/_images/core_type_mappings.svg | 3485 ++++++++++++++++- docs/source/api.rst | 53 +- pyproject.toml | 11 +- requirements-dev.txt | 2 + src/neo4j/_codec/hydration/__init__.py | 2 + src/neo4j/_codec/hydration/_common.py | 41 +- .../_codec/hydration/_interface/__init__.py | 5 +- .../_codec/hydration/v1/hydration_handler.py | 42 +- src/neo4j/_codec/hydration/v1/temporal.py | 109 + .../_codec/hydration/v2/hydration_handler.py | 16 +- src/neo4j/_codec/hydration/v2/temporal.py | 47 + src/neo4j/_codec/packstream/v1/__init__.py | 98 +- src/neo4j/time/__init__.py | 2 +- .../hydration/v1/test_hydration_handler.py | 16 +- .../hydration/v1/test_spacial_dehydration.py | 33 +- .../hydration/v1/test_temporal_dehydration.py | 296 +- .../v1/test_temporal_dehydration_utc_patch.py | 61 + .../hydration/v2/test_temporal_dehydration.py | 81 +- .../codec/packstream/v1/test_packstream.py | 503 ++- 20 files changed, 4570 insertions(+), 333 deletions(-) create mode 100644 tests/unit/common/codec/hydration/v1/test_temporal_dehydration_utc_patch.py diff --git a/docs/source/_images/core_type_mappings.png b/docs/source/_images/core_type_mappings.png index 180e1fb26e8e955ad6adddba1dd799b383e2d9a0..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 61710 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 diff --git a/docs/source/_images/core_type_mappings.svg b/docs/source/_images/core_type_mappings.svg index 84e06d927..5b29bba3b 100644 --- a/docs/source/_images/core_type_mappings.svg +++ b/docs/source/_images/core_type_mappings.svg @@ -1 +1,3484 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/api.rst b/docs/source/api.rst index e522425fe..5607eb2b2 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -1091,7 +1091,7 @@ The core types with their general mappings are listed below: +------------------------+---------------------------------------------------------------------------------------------------------------------------+ | String | :class:`str` | +------------------------+---------------------------------------------------------------------------------------------------------------------------+ -| Bytes :sup:`[1]` | :class:`bytearray` | +| Bytes :sup:`[1]` | :class:`bytes` | +------------------------+---------------------------------------------------------------------------------------------------------------------------+ | List | :class:`list` | +------------------------+---------------------------------------------------------------------------------------------------------------------------+ @@ -1110,6 +1110,57 @@ The diagram below illustrates the actual mappings between the various layers, fr :target: ./_images/core_type_mappings.svg +Extended Data Types +=================== + +The driver supports serializing more types (as parameters in). +However, they will have to be mapped to the existing Bolt types (see above) when they are sent to the server. +This means, the driver will never return these types in results. + +When in doubt, you can test the type conversion like so:: + + import neo4j + + + with neo4j.GraphDatabase.driver(URI, auth=AUTH) as driver: + with driver.session() as session: + type_in = ("foo", "bar") + result = session.run("RETURN $x", x=type_in) + type_out = result.single()[0] + print(type(type_out)) + print(type_out) + +Which in this case would yield:: + + + ['foo', 'bar'] + + ++-----------------------------------+---------------------------------+---------------------------------------+ +| Parameter Type | Bolt Type | Result Type | ++===================================+=================================+=======================================+ +| :class:`tuple` | List | :class:`list` | ++-----------------------------------+---------------------------------+---------------------------------------+ +| :class:`bytearray` | Bytes | :class:`bytes` | ++-----------------------------------+---------------------------------+---------------------------------------+ +| numpy\ :sup:`[2]` ``ndarray`` | (nested) List | (nested) :class:`list` | ++-----------------------------------+---------------------------------+---------------------------------------+ +| pandas\ :sup:`[3]` ``DataFrame`` | Map[str, List[_]] :sup:`[4]` | :class:`dict` | ++-----------------------------------+---------------------------------+---------------------------------------+ +| pandas ``Series`` | List | :class:`list` | ++-----------------------------------+---------------------------------+---------------------------------------+ +| pandas ``Array`` | List | :class:`list` | ++-----------------------------------+---------------------------------+---------------------------------------+ + +.. Note:: + + 2. ``void`` and ``complexfloating`` typed numpy ``ndarray``\s are not supported. + 3. ``Period``, ``Interval``, and ``pyarrow`` pandas types are not supported. + 4. A pandas ``DataFrame`` will be serialized Map from with the column names mapping to the column values (as lists). + Just like with ``dict`` objects, the column names need to be :class:`str` objects. + + + **************** Graph Data Types **************** diff --git a/pyproject.toml b/pyproject.toml index f16454683..08387ce8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,17 @@ dynamic = ["version", "readme"] Homepage = "https://github.com/neo4j/neo4j-python-driver" [project.optional-dependencies] -pandas = ["pandas>=1.0.0"] +numpy = ["numpy >= 1.7.0, < 2.0.0"] +pandas = [ + "pandas >= 1.1.0, < 2.0.0", + "numpy >= 1.7.0, < 2.0.0", +] [build-system] -requires = ["setuptools~=65.6", "tomlkit~=0.11.6"] +requires = [ + "setuptools~=65.6", + "tomlkit~=0.11.6", +] build-backend = "setuptools.build_meta" # still in beta diff --git a/requirements-dev.txt b/requirements-dev.txt index 04528c610..b070002c1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,9 @@ tomlkit~=0.11.6 # needed for running tests coverage[toml]>=5.5 mock>=4.0.3 +numpy>=1.7.0 pandas>=1.0.0 +pyarrow>=1.0.0 pytest>=6.2.5 pytest-asyncio>=0.16.0 pytest-benchmark>=3.4.1 diff --git a/src/neo4j/_codec/hydration/__init__.py b/src/neo4j/_codec/hydration/__init__.py index 1df9aaaa0..42e44f98a 100644 --- a/src/neo4j/_codec/hydration/__init__.py +++ b/src/neo4j/_codec/hydration/__init__.py @@ -17,6 +17,7 @@ from ._common import ( BrokenHydrationObject, + DehydrationHooks, HydrationScope, ) from ._interface import HydrationHandlerABC @@ -24,6 +25,7 @@ __all__ = [ "BrokenHydrationObject", + "DehydrationHooks", "HydrationHandlerABC", "HydrationScope", ] diff --git a/src/neo4j/_codec/hydration/_common.py b/src/neo4j/_codec/hydration/_common.py index 2632f2117..f278dc77f 100644 --- a/src/neo4j/_codec/hydration/_common.py +++ b/src/neo4j/_codec/hydration/_common.py @@ -16,12 +16,51 @@ # limitations under the License. +import typing as t from copy import copy +from dataclasses import dataclass from ...graph import Graph from ..packstream import Structure +@dataclass +class DehydrationHooks: + exact_types: t.Dict[t.Type, t.Callable[[t.Any], t.Any]] + subtypes: t.Dict[t.Type, t.Callable[[t.Any], t.Any]] + + def update(self, exact_types=None, subtypes=None): + exact_types = exact_types or {} + subtypes = subtypes or {} + self.exact_types.update(exact_types) + self.subtypes.update(subtypes) + + def extend(self, exact_types=None, subtypes=None): + exact_types = exact_types or {} + subtypes = subtypes or {} + return DehydrationHooks( + exact_types={**self.exact_types, **exact_types}, + subtypes={**self.subtypes, **subtypes}, + ) + + def get_transformer(self, item): + type_ = type(item) + transformer = self.exact_types.get(type_) + if transformer is not None: + return transformer + transformer = next( + ( + f + for super_type, f in self.subtypes.items() + if isinstance(item, super_type) + ), + None, + ) + if transformer is not None: + return transformer + return None + + class BrokenHydrationObject: """ Represents an object from the server, not understood by the driver. @@ -68,7 +107,7 @@ def __init__(self, hydration_handler, graph_hydrator): list: self._hydrate_list, dict: self._hydrate_dict, } - self.dehydration_hooks = hydration_handler.dehydration_functions + self.dehydration_hooks = hydration_handler.dehydration_hooks def _hydrate_structure(self, value): f = self._struct_hydration_functions.get(value.tag) diff --git a/src/neo4j/_codec/hydration/_interface/__init__.py b/src/neo4j/_codec/hydration/_interface/__init__.py index 5092d5e0d..093b98597 100644 --- a/src/neo4j/_codec/hydration/_interface/__init__.py +++ b/src/neo4j/_codec/hydration/_interface/__init__.py @@ -18,11 +18,14 @@ import abc +from .._common import DehydrationHooks + class HydrationHandlerABC(abc.ABC): def __init__(self): self.struct_hydration_functions = {} - self.dehydration_functions = {} + self.dehydration_hooks = DehydrationHooks(exact_types={}, + subtypes={}) @abc.abstractmethod def new_hydration_scope(self): diff --git a/src/neo4j/_codec/hydration/v1/hydration_handler.py b/src/neo4j/_codec/hydration/v1/hydration_handler.py index 89839503f..994b26d81 100644 --- a/src/neo4j/_codec/hydration/v1/hydration_handler.py +++ b/src/neo4j/_codec/hydration/v1/hydration_handler.py @@ -23,6 +23,21 @@ timedelta, ) + +try: + import numpy as np + + NUMPY_AVAILABLE = True +except ImportError: + NUMPY_AVAILABLE = False + +try: + import pandas as pd + + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + from ....graph import ( Graph, Node, @@ -159,8 +174,7 @@ def __init__(self): b"d": temporal.hydrate_datetime, # no time zone b"E": temporal.hydrate_duration, } - self.dehydration_functions = { - **self.dehydration_functions, + self.dehydration_hooks.update(exact_types={ Point: spatial.dehydrate_point, CartesianPoint: spatial.dehydrate_point, WGS84Point: spatial.dehydrate_point, @@ -172,7 +186,19 @@ def __init__(self): datetime: temporal.dehydrate_datetime, Duration: temporal.dehydrate_duration, timedelta: temporal.dehydrate_timedelta, - } + }) + if NUMPY_AVAILABLE: + self.dehydration_hooks.update(exact_types={ + np.datetime64: temporal.dehydrate_np_datetime, + np.timedelta64: temporal.dehydrate_np_timedelta, + }) + if PANDAS_AVAILABLE: + self.dehydration_hooks.update(exact_types={ + pd.Timestamp: temporal.dehydrate_pandas_datetime, + pd.Timedelta: temporal.dehydrate_pandas_timedelta, + type(pd.NaT): lambda _: None, + }) + def patch_utc(self): from ..v2 import temporal as temporal_v2 @@ -186,10 +212,18 @@ def patch_utc(self): b"i": temporal_v2.hydrate_datetime, }) - self.dehydration_functions.update({ + self.dehydration_hooks.update(exact_types={ DateTime: temporal_v2.dehydrate_datetime, datetime: temporal_v2.dehydrate_datetime, }) + if NUMPY_AVAILABLE: + self.dehydration_hooks.update(exact_types={ + np.datetime64: temporal_v2.dehydrate_np_datetime, + }) + if PANDAS_AVAILABLE: + self.dehydration_hooks.update(exact_types={ + pd.Timestamp: temporal_v2.dehydrate_pandas_datetime, + }) def new_hydration_scope(self): self._created_scope = True diff --git a/src/neo4j/_codec/hydration/v1/temporal.py b/src/neo4j/_codec/hydration/v1/temporal.py index c36eda5d3..c9f4bf3cf 100644 --- a/src/neo4j/_codec/hydration/v1/temporal.py +++ b/src/neo4j/_codec/hydration/v1/temporal.py @@ -22,10 +22,28 @@ timedelta, ) + +try: + import numpy as np + + NUMPY_AVAILABLE = True +except ImportError: + NUMPY_AVAILABLE = False + +try: + import pandas as pd + + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + from ....time import ( Date, DateTime, Duration, + MAX_YEAR, + MIN_YEAR, + NANO_SECONDS, Time, ) from ...packstream import Structure @@ -171,6 +189,50 @@ def seconds_and_nanoseconds(dt): int(tz.utcoffset(value).total_seconds())) +if NUMPY_AVAILABLE: + def dehydrate_np_datetime(value): + """ Dehydrator for `numpy.datetime64` values. + + :param value: + :type value: numpy.datetime64 + :returns: + """ + if np.isnat(value): + return None + year = value.astype("datetime64[Y]").astype(int) + 1970 + if not 0 < year <= 9999: + # while we could encode years outside the range, they would fail + # when retrieved from the database. + raise ValueError(f"Year out of range ({MIN_YEAR:d}..{MAX_YEAR:d}) " + f"found {year}") + seconds = value.astype(np.dtype("datetime64[s]")).astype(int) + nanoseconds = (value.astype(np.dtype("datetime64[ns]")).astype(int) + % NANO_SECONDS) + return Structure(b"d", seconds, nanoseconds) + + +if PANDAS_AVAILABLE: + def dehydrate_pandas_datetime(value): + """ Dehydrator for `pandas.Timestamp` values. + + :param value: + :type value: pandas.Timestamp + :returns: + """ + return dehydrate_datetime( + DateTime( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.microsecond * 1000 + value.nanosecond, + value.tzinfo, + ) + ) + + def hydrate_duration(months, days, seconds, nanoseconds): """ Hydrator for `Duration` values. @@ -205,3 +267,50 @@ def dehydrate_timedelta(value): seconds = value.seconds nanoseconds = 1000 * value.microseconds return Structure(b"E", months, days, seconds, nanoseconds) + + +if NUMPY_AVAILABLE: + _NUMPY_DURATION_UNITS = { + "Y": "years", + "M": "months", + "W": "weeks", + "D": "days", + "h": "hours", + "m": "minutes", + "s": "seconds", + "ms": "milliseconds", + "us": "microseconds", + "ns": "nanoseconds", + } + + def dehydrate_np_timedelta(value): + """ Dehydrator for `numpy.timedelta64` values. + + :param value: + :type value: numpy.timedelta64 + :returns: + """ + if np.isnat(value): + return None + unit, step_size = np.datetime_data(value) + numer = int(value.astype(int)) + # raise RuntimeError((type(numer), type(step_size))) + kwarg = _NUMPY_DURATION_UNITS.get(unit) + if kwarg is not None: + return dehydrate_duration(Duration(**{kwarg: numer * step_size})) + return dehydrate_duration(Duration( + nanoseconds=value.astype("timedelta64[ns]").astype(int) + )) + + +if PANDAS_AVAILABLE: + def dehydrate_pandas_timedelta(value): + """ Dehydrator for `pandas.Timedelta` values. + + :param value: + :type value: pandas.Timedelta + :returns: + """ + return dehydrate_duration(Duration( + nanoseconds=value.value + )) diff --git a/src/neo4j/_codec/hydration/v2/hydration_handler.py b/src/neo4j/_codec/hydration/v2/hydration_handler.py index 167fab991..94d4e82db 100644 --- a/src/neo4j/_codec/hydration/v2/hydration_handler.py +++ b/src/neo4j/_codec/hydration/v2/hydration_handler.py @@ -37,8 +37,7 @@ def __init__(self): b"d": temporal.hydrate_datetime, # no time zone b"E": temporal.hydrate_duration, } - self.dehydration_functions = { - **self.dehydration_functions, + self.dehydration_hooks.update(exact_types={ Point: spatial.dehydrate_point, CartesianPoint: spatial.dehydrate_point, WGS84Point: spatial.dehydrate_point, @@ -50,7 +49,18 @@ def __init__(self): datetime: temporal.dehydrate_datetime, Duration: temporal.dehydrate_duration, timedelta: temporal.dehydrate_timedelta, - } + }) + if NUMPY_AVAILABLE: + self.dehydration_hooks.update(exact_types={ + np.datetime64: temporal.dehydrate_np_datetime, + np.timedelta64: temporal.dehydrate_np_timedelta, + }) + if PANDAS_AVAILABLE: + self.dehydration_hooks.update(exact_types={ + pd.Timestamp: temporal.dehydrate_pandas_datetime, + pd.Timedelta: temporal.dehydrate_pandas_timedelta, + type(pd.NaT): lambda _: None, + }) def new_hydration_scope(self): self._created_scope = True diff --git a/src/neo4j/_codec/hydration/v2/temporal.py b/src/neo4j/_codec/hydration/v2/temporal.py index bc3644587..3ed1e4596 100644 --- a/src/neo4j/_codec/hydration/v2/temporal.py +++ b/src/neo4j/_codec/hydration/v2/temporal.py @@ -16,6 +16,7 @@ # limitations under the License. +from ....time import UnixEpoch from ..v1.temporal import * @@ -90,3 +91,49 @@ def seconds_and_nanoseconds(dt): "UTC offsets.") offset_seconds = offset.days * 86400 + offset.seconds return Structure(b"I", seconds, nanoseconds, offset_seconds) + + +if PANDAS_AVAILABLE: + def dehydrate_pandas_datetime(value): + """ Dehydrator for `pandas.Timestamp` values. + + :param value: + :type value: pandas.Timestamp + :returns: + """ + seconds, nanoseconds = divmod(value.value, NANO_SECONDS) + + import pytz + + tz = value.tzinfo + if tz is None: + # without time zone + return Structure(b"d", seconds, nanoseconds) + elif hasattr(tz, "zone") and tz.zone and isinstance(tz.zone, str): + # with named pytz time zone + return Structure(b"i", seconds, nanoseconds, tz.zone) + elif hasattr(tz, "key") and tz.key and isinstance(tz.key, str): + # with named zoneinfo (Python 3.9+) time zone + return Structure(b"i", seconds, nanoseconds, tz.key) + else: + # with time offset + offset = tz.utcoffset(value) + if offset.microseconds: + raise ValueError("Bolt protocol does not support sub-second " + "UTC offsets.") + offset_seconds = offset.days * 86400 + offset.seconds + return Structure(b"I", seconds, nanoseconds, offset_seconds) + + # simpler but slower alternative + # return dehydrate_datetime( + # DateTime( + # value.year, + # value.month, + # value.day, + # value.hour, + # value.minute, + # value.second, + # value.microsecond * 1000 + value.nanosecond, + # value.tzinfo, + # ) + # ) diff --git a/src/neo4j/_codec/packstream/v1/__init__.py b/src/neo4j/_codec/packstream/v1/__init__.py index d2f9caf4d..efd3db74c 100644 --- a/src/neo4j/_codec/packstream/v1/__init__.py +++ b/src/neo4j/_codec/packstream/v1/__init__.py @@ -16,6 +16,7 @@ # limitations under the License. +import typing as t from codecs import decode from contextlib import contextmanager from struct import ( @@ -26,6 +27,41 @@ from .._common import Structure +NONE_VALUES: t.Tuple = (None,) +TRUE_VALUES: t.Tuple = (True,) +FALSE_VALUES: t.Tuple = (False,) +INT_TYPES: t.Tuple[t.Type, ...] = (int,) +FLOAT_TYPES: t.Tuple[t.Type, ...] = (float,) +SEQUENCE_TYPES: t.Tuple[t.Type, ...] = (list, tuple) +MAPPING_TYPES: t.Tuple[t.Type, ...] = (dict,) +BYTES_TYPES: t.Tuple[t.Type, ...] = (bytes, bytearray) + + +try: + import numpy as np + + TRUE_VALUES += (np.bool_(True),) + FALSE_VALUES += (np.bool_(False),) + INT_TYPES = (*INT_TYPES, np.integer) + FLOAT_TYPES = (*FLOAT_TYPES, np.floating) + SEQUENCE_TYPES = (*SEQUENCE_TYPES, np.ndarray) + NUMPY_AVAILABLE = True +except ImportError: + NUMPY_AVAILABLE = False + +try: + import pandas as pd + import pandas.core.arrays + + NONE_VALUES += (pd.NA,) + SEQUENCE_TYPES = (*SEQUENCE_TYPES, pd.Series, pd.Categorical, + pd.core.arrays.ExtensionArray) + MAPPING_TYPES = (*MAPPING_TYPES, pd.DataFrame) + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + + PACKED_UINT_8 = [struct_pack(">B", value) for value in range(0x100)] PACKED_UINT_16 = [struct_pack(">H", value) for value in range(0x10000)] @@ -42,37 +78,37 @@ def __init__(self, stream): self.stream = stream self._write = self.stream.write - def pack_raw(self, data): + def _pack_raw(self, data): self._write(data) def pack(self, value, dehydration_hooks=None): write = self._write # None - if value is None: + if any(value is v for v in NONE_VALUES): write(b"\xC0") # NULL # Boolean - elif value is True: + elif any(value is v for v in TRUE_VALUES): write(b"\xC3") - elif value is False: + elif any(value is v for v in FALSE_VALUES): write(b"\xC2") # Float (only double precision is supported) - elif isinstance(value, float): + elif isinstance(value, FLOAT_TYPES): write(b"\xC1") write(struct_pack(">d", value)) # Integer - elif isinstance(value, int): + elif isinstance(value, INT_TYPES): if -0x10 <= value < 0x80: - write(PACKED_UINT_8[value % 0x100]) + write(PACKED_UINT_8[int(value % 0x100)]) elif -0x80 <= value < -0x10: write(b"\xC8") - write(PACKED_UINT_8[value % 0x100]) + write(PACKED_UINT_8[int(value % 0x100)]) elif -0x8000 <= value < 0x8000: write(b"\xC9") - write(PACKED_UINT_16[value % 0x10000]) + write(PACKED_UINT_16[int(value % 0x10000)]) elif -0x80000000 <= value < 0x80000000: write(b"\xCA") write(struct_pack(">i", value)) @@ -85,42 +121,46 @@ def pack(self, value, dehydration_hooks=None): # String elif isinstance(value, str): encoded = value.encode("utf-8") - self.pack_string_header(len(encoded)) - self.pack_raw(encoded) + self._pack_string_header(len(encoded)) + self._pack_raw(encoded) # Bytes - elif isinstance(value, (bytes, bytearray)): - self.pack_bytes_header(len(value)) - self.pack_raw(value) + elif isinstance(value, BYTES_TYPES): + self._pack_bytes_header(len(value)) + self._pack_raw(value) # List - elif isinstance(value, list): - self.pack_list_header(len(value)) + elif isinstance(value, SEQUENCE_TYPES): + self._pack_list_header(len(value)) for item in value: - self.pack(item, dehydration_hooks=dehydration_hooks) + self.pack(item, dehydration_hooks) # Map - elif isinstance(value, dict): - self.pack_map_header(len(value)) + elif isinstance(value, MAPPING_TYPES): + self._pack_map_header(len(value.keys())) for key, item in value.items(): if not isinstance(key, str): raise TypeError( "Map keys must be strings, not {}".format(type(key)) ) - self.pack(key, dehydration_hooks=dehydration_hooks) - self.pack(item, dehydration_hooks=dehydration_hooks) + self.pack(key, dehydration_hooks) + self.pack(item, dehydration_hooks) # Structure elif isinstance(value, Structure): self.pack_struct(value.tag, value.fields) - # Other - elif dehydration_hooks and type(value) in dehydration_hooks: - self.pack(dehydration_hooks[type(value)](value)) + # Other if in dehydration hooks else: + if dehydration_hooks: + transformer = dehydration_hooks.get_transformer(value) + if transformer is not None: + self.pack(transformer(value), dehydration_hooks) + return + raise ValueError("Values of type %s are not supported" % type(value)) - def pack_bytes_header(self, size): + def _pack_bytes_header(self, size): write = self._write if size < 0x100: write(b"\xCC") @@ -134,7 +174,7 @@ def pack_bytes_header(self, size): else: raise OverflowError("Bytes header size out of range") - def pack_string_header(self, size): + def _pack_string_header(self, size): write = self._write if size <= 0x0F: write(bytes((0x80 | size,))) @@ -150,7 +190,7 @@ def pack_string_header(self, size): else: raise OverflowError("String header size out of range") - def pack_list_header(self, size): + def _pack_list_header(self, size): write = self._write if size <= 0x0F: write(bytes((0x90 | size,))) @@ -166,7 +206,7 @@ def pack_list_header(self, size): else: raise OverflowError("List header size out of range") - def pack_map_header(self, size): + def _pack_map_header(self, size): write = self._write if size <= 0x0F: write(bytes((0xA0 | size,))) @@ -193,7 +233,7 @@ def pack_struct(self, signature, fields, dehydration_hooks=None): raise OverflowError("Structure size out of range") write(signature) for field in fields: - self.pack(field, dehydration_hooks=dehydration_hooks) + self.pack(field, dehydration_hooks) @staticmethod def new_packable_buffer(): diff --git a/src/neo4j/time/__init__.py b/src/neo4j/time/__init__.py index 0ddc84289..faf8061c8 100644 --- a/src/neo4j/time/__init__.py +++ b/src/neo4j/time/__init__.py @@ -412,7 +412,7 @@ def __new__( + d * AVERAGE_SECONDS_IN_DAY + s - (1 if ns < 0 else 0)) - if avg_total_seconds < MIN_INT64 or avg_total_seconds > MAX_INT64: + if not MIN_INT64 <= avg_total_seconds <= MAX_INT64: raise ValueError("Duration value out of range: %r", tuple.__repr__((mo, d, s, ns))) return tuple.__new__(cls, (mo, d, s, ns)) diff --git a/tests/unit/common/codec/hydration/v1/test_hydration_handler.py b/tests/unit/common/codec/hydration/v1/test_hydration_handler.py index 908678c9d..6c00005d1 100644 --- a/tests/unit/common/codec/hydration/v1/test_hydration_handler.py +++ b/tests/unit/common/codec/hydration/v1/test_hydration_handler.py @@ -23,9 +23,14 @@ timedelta, ) +import numpy as np +import pandas as pd import pytest -from neo4j._codec.hydration import HydrationScope +from neo4j._codec.hydration import ( + DehydrationHooks, + HydrationScope, +) from neo4j._codec.hydration.v1 import HydrationHandler from neo4j._codec.packstream import Structure from neo4j.graph import Graph @@ -64,12 +69,15 @@ def test_scope_hydration_keys(self, hydration_scope): def test_scope_dehydration_keys(self, hydration_scope): hooks = hydration_scope.dehydration_hooks - assert isinstance(hooks, dict) - assert set(hooks.keys()) == { + assert isinstance(hooks, DehydrationHooks) + assert set(hooks.exact_types.keys()) == { date, datetime, time, timedelta, Date, DateTime, Duration, Time, - CartesianPoint, Point, WGS84Point + CartesianPoint, Point, WGS84Point, + np.datetime64, np.timedelta64, + pd.Timestamp, pd.Timedelta, type(pd.NaT) } + assert not hooks.subtypes def test_scope_get_graph(self, hydration_scope): graph = hydration_scope.get_graph() diff --git a/tests/unit/common/codec/hydration/v1/test_spacial_dehydration.py b/tests/unit/common/codec/hydration/v1/test_spacial_dehydration.py index 6486cea52..05c190457 100644 --- a/tests/unit/common/codec/hydration/v1/test_spacial_dehydration.py +++ b/tests/unit/common/codec/hydration/v1/test_spacial_dehydration.py @@ -34,40 +34,49 @@ class TestSpatialDehydration(HydrationHandlerTestBase): def hydration_handler(self): return HydrationHandler() - def test_cartesian_2d(self, hydration_scope): + @pytest.fixture + def transformer(self, hydration_scope): + def transformer(value): + transformer_ = \ + hydration_scope.dehydration_hooks.get_transformer(value) + assert callable(transformer_) + return transformer_(value) + return transformer + + def test_cartesian_2d(self, transformer): point = CartesianPoint((1, 3.1)) - struct = hydration_scope.dehydration_hooks[type(point)](point) + struct = transformer(point) assert struct == Structure(b"X", 7203, 1.0, 3.1) assert all(isinstance(f, float) for f in struct.fields[1:]) - def test_cartesian_3d(self, hydration_scope): + def test_cartesian_3d(self, transformer): point = CartesianPoint((1, -2, 3.1)) - struct = hydration_scope.dehydration_hooks[type(point)](point) + struct = transformer(point) assert struct == Structure(b"Y", 9157, 1.0, -2.0, 3.1) assert all(isinstance(f, float) for f in struct.fields[1:]) - def test_wgs84_2d(self, hydration_scope): + def test_wgs84_2d(self, transformer): point = WGS84Point((1, 3.1)) - struct = hydration_scope.dehydration_hooks[type(point)](point) + struct = transformer(point) assert struct == Structure(b"X", 4326, 1.0, 3.1) assert all(isinstance(f, float) for f in struct.fields[1:]) - def test_wgs84_3d(self, hydration_scope): + def test_wgs84_3d(self, transformer): point = WGS84Point((1, -2, 3.1)) - struct = hydration_scope.dehydration_hooks[type(point)](point) + struct = transformer(point) assert struct == Structure(b"Y", 4979, 1.0, -2.0, 3.1) assert all(isinstance(f, float) for f in struct.fields[1:]) - def test_custom_point_2d(self, hydration_scope): + def test_custom_point_2d(self, transformer): point = Point((1, 3.1)) point.srid = 12345 - struct = hydration_scope.dehydration_hooks[type(point)](point) + struct = transformer(point) assert struct == Structure(b"X", 12345, 1.0, 3.1) assert all(isinstance(f, float) for f in struct.fields[1:]) - def test_custom_point_3d(self, hydration_scope): + def test_custom_point_3d(self, transformer): point = Point((1, -2, 3.1)) point.srid = 12345 - struct = hydration_scope.dehydration_hooks[type(point)](point) + struct = transformer(point) assert struct == Structure(b"Y", 12345, 1.0, -2.0, 3.1) assert all(isinstance(f, float) for f in struct.fields[1:]) diff --git a/tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py b/tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py index 078fc6e7f..c783cefed 100644 --- a/tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py +++ b/tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py @@ -18,15 +18,21 @@ import datetime +import numpy as np +import pandas as pd import pytest import pytz from neo4j._codec.hydration.v1 import HydrationHandler from neo4j._codec.packstream import Structure from neo4j.time import ( + AVERAGE_SECONDS_IN_DAY, Date, DateTime, Duration, + MAX_INT64, + MIN_INT64, + NANO_SECONDS, Time, ) @@ -38,156 +44,220 @@ class TestTimeDehydration(HydrationHandlerTestBase): def hydration_handler(self): return HydrationHandler() - def test_date(self, hydration_scope): + @pytest.fixture + def transformer(self, hydration_scope): + def transformer(value): + transformer_ = \ + hydration_scope.dehydration_hooks.get_transformer(value) + assert callable(transformer_) + return transformer_(value) + return transformer + + @pytest.fixture + def assert_transforms(self, transformer): + def assert_(value, expected): + struct = transformer(value) + assert struct == expected + return assert_ + + def test_date(self, assert_transforms): date = Date(1991, 8, 24) - struct = hydration_scope.dehydration_hooks[type(date)](date) - assert struct == Structure(b"D", 7905) + assert_transforms(date, Structure(b"D", 7905)) - def test_native_date(self, hydration_scope): + def test_native_date(self, assert_transforms): date = datetime.date(1991, 8, 24) - struct = hydration_scope.dehydration_hooks[type(date)](date) - assert struct == Structure(b"D", 7905) + assert_transforms(date, Structure(b"D", 7905)) - def test_time(self, hydration_scope): + def test_time(self, assert_transforms): time = Time(1, 2, 3, 4, pytz.FixedOffset(60)) - struct = hydration_scope.dehydration_hooks[type(time)](time) - assert struct == Structure(b"T", 3723000000004, 3600) + assert_transforms(time, Structure(b"T", 3723000000004, 3600)) - def test_native_time(self, hydration_scope): + def test_native_time(self, assert_transforms): time = datetime.time(1, 2, 3, 4, pytz.FixedOffset(60)) - struct = hydration_scope.dehydration_hooks[type(time)](time) - assert struct == Structure(b"T", 3723000004000, 3600) + assert_transforms(time, Structure(b"T", 3723000004000, 3600)) - def test_local_time(self, hydration_scope): + def test_local_time(self, assert_transforms): time = Time(1, 2, 3, 4) - struct = hydration_scope.dehydration_hooks[type(time)](time) - assert struct == Structure(b"t", 3723000000004) + assert_transforms(time, Structure(b"t", 3723000000004)) - def test_local_native_time(self, hydration_scope): + def test_local_native_time(self, assert_transforms): time = datetime.time(1, 2, 3, 4) - struct = hydration_scope.dehydration_hooks[type(time)](time) - assert struct == Structure(b"t", 3723000004000) + assert_transforms(time, Structure(b"t", 3723000004000)) + + def test_local_date_time(self, assert_transforms): + dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862) + assert_transforms(dt, Structure(b"d", 1539344261, 474716862)) + + def test_native_local_date_time(self, assert_transforms): + dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716) + assert_transforms(dt, Structure(b"d", 1539344261, 474716000)) + + def test_numpy_local_date_time(self, assert_transforms): + dt = np.datetime64("2018-10-12T11:37:41.474716862") + assert_transforms(dt, Structure(b"d", 1539344261, 474716862)) + + def test_numpy_nat_local_date_time(self, assert_transforms): + dt = np.datetime64("NaT") + assert_transforms(dt, None) + + @pytest.mark.parametrize(("value", "error"), ( + (np.datetime64(10000 - 1970, "Y"), ValueError), + (np.datetime64("+10000-01-01"), ValueError), + (np.datetime64(-1970, "Y"), ValueError), + (np.datetime64("0000-12-31"), ValueError), - def test_date_time(self, hydration_scope): + )) + def test_numpy_invalid_local_date_time(self, value, error, transformer): + with pytest.raises(error): + transformer(value) + + def test_pandas_local_date_time(self, assert_transforms): + dt = pd.Timestamp("2018-10-12T11:37:41.474716862") + assert_transforms(dt, Structure(b"d", 1539344261, 474716862)) + + def test_pandas_nat_local_date_time(self, assert_transforms): + dt = pd.NaT + assert_transforms(dt, None) + + def test_date_time_fixed_offset(self, assert_transforms): dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, pytz.FixedOffset(60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"F", 1539344261, 474716862, 3600) + assert_transforms(dt, Structure(b"F", 1539344261, 474716862, 3600)) - def test_native_date_time(self, hydration_scope): + def test_native_date_time_fixed_offset(self, assert_transforms): dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, pytz.FixedOffset(60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"F", 1539344261, 474716000, 3600) + assert_transforms(dt, Structure(b"F", 1539344261, 474716000, 3600)) - def test_date_time_negative_offset(self, hydration_scope): + def test_pandas_date_time_fixed_offset(self, assert_transforms): + dt = pd.Timestamp("2018-10-12T11:37:41.474716862+0100") + assert_transforms(dt, Structure(b"F", 1539344261, 474716862, 3600)) + + def test_date_time_fixed_negative_offset(self, assert_transforms): dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, pytz.FixedOffset(-60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"F", 1539344261, 474716862, -3600) + assert_transforms(dt, Structure(b"F", 1539344261, 474716862, -3600)) - def test_native_date_time_negative_offset(self, hydration_scope): + def test_native_date_time_fixed_negative_offset(self, assert_transforms): dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, pytz.FixedOffset(-60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"F", 1539344261, 474716000, -3600) + assert_transforms(dt, Structure(b"F", 1539344261, 474716000, -3600)) + + def test_pandas_date_time_fixed_negative_offset(self, assert_transforms): + dt = pd.Timestamp("2018-10-12T11:37:41.474716862-0100") + assert_transforms(dt, Structure(b"F", 1539344261, 474716862, -3600)) - def test_date_time_zone_id(self, hydration_scope): + def test_date_time_zone_id(self, assert_transforms): dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, pytz.timezone("Europe/Stockholm")) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"f", 1539344261, 474716862, - "Europe/Stockholm") + assert_transforms( + dt, + Structure(b"f", 1539344261, 474716862, "Europe/Stockholm") + ) - def test_native_date_time_zone_id(self, hydration_scope): + def test_native_date_time_zone_id(self, assert_transforms): dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, pytz.timezone("Europe/Stockholm")) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"f", 1539344261, 474716000, - "Europe/Stockholm") - - def test_local_date_time(self, hydration_scope): - dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"d", 1539344261, 474716862) + assert_transforms( + dt, + Structure(b"f", 1539344261, 474716000, "Europe/Stockholm") + ) - def test_native_local_date_time(self, hydration_scope): - dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"d", 1539344261, 474716000) + def test_pandas_date_time_zone_id(self, assert_transforms): + dt = pd.Timestamp("2018-10-12T11:37:41.474716862+0200", + tz="Europe/Stockholm") + assert_transforms( + dt, + Structure(b"f", 1539344261, 474716862, "Europe/Stockholm") + ) - def test_duration(self, hydration_scope): + def test_duration(self, assert_transforms): duration = Duration(months=1, days=2, seconds=3, nanoseconds=4) - struct = hydration_scope.dehydration_hooks[type(duration)](duration) - assert struct == Structure(b"E", 1, 2, 3, 4) + assert_transforms(duration, Structure(b"E", 1, 2, 3, 4)) - def test_native_duration(self, hydration_scope): + def test_native_duration(self, assert_transforms): duration = datetime.timedelta(days=1, seconds=2, microseconds=3) - struct = hydration_scope.dehydration_hooks[type(duration)](duration) - assert struct == Structure(b"E", 0, 1, 2, 3000) + assert_transforms(duration, Structure(b"E", 0, 1, 2, 3000)) - def test_duration_mixed_sign(self, hydration_scope): + def test_duration_mixed_sign(self, assert_transforms): duration = Duration(months=1, days=-2, seconds=3, nanoseconds=4) - struct = hydration_scope.dehydration_hooks[type(duration)](duration) - assert struct == Structure(b"E", 1, -2, 3, 4) + assert_transforms(duration, Structure(b"E", 1, -2, 3, 4)) - def test_native_duration_mixed_sign(self, hydration_scope): + def test_native_duration_mixed_sign(self, assert_transforms): duration = datetime.timedelta(days=-1, seconds=2, microseconds=3) - struct = hydration_scope.dehydration_hooks[type(duration)](duration) - assert struct == Structure(b"E", 0, -1, 2, 3000) - - -class TestUTCPatchedTimeDehydration(TestTimeDehydration): - @pytest.fixture - def hydration_handler(self): - handler = HydrationHandler() - handler.patch_utc() - return handler - - def test_date_time(self, hydration_scope): - from ..v2.test_temporal_dehydration import ( - TestTimeDehydration as TestTimeDehydrationV2, - ) - TestTimeDehydrationV2().test_date_time( - hydration_scope - ) - - def test_native_date_time(self, hydration_scope): - from ..v2.test_temporal_dehydration import ( - TestTimeDehydration as TestTimeDehydrationV2, - ) - TestTimeDehydrationV2().test_native_date_time( - hydration_scope - ) - - def test_date_time_negative_offset(self, hydration_scope): - from ..v2.test_temporal_dehydration import ( - TestTimeDehydration as TestTimeDehydrationV2, - ) - TestTimeDehydrationV2().test_date_time_negative_offset( - hydration_scope - ) - - def test_native_date_time_negative_offset(self, hydration_scope): - from ..v2.test_temporal_dehydration import ( - TestTimeDehydration as TestTimeDehydrationV2, - ) - TestTimeDehydrationV2().test_native_date_time_negative_offset( - hydration_scope - ) - - def test_date_time_zone_id(self, hydration_scope): - from ..v2.test_temporal_dehydration import ( - TestTimeDehydration as TestTimeDehydrationV2, - ) - TestTimeDehydrationV2().test_date_time_zone_id( - hydration_scope - ) - - def test_native_date_time_zone_id(self, hydration_scope): - from ..v2.test_temporal_dehydration import ( - TestTimeDehydration as TestTimeDehydrationV2, + assert_transforms(duration, Structure(b"E", 0, -1, 2, 3000)) + + @pytest.mark.parametrize( + ("value", "expected_fields"), + ( + (np.timedelta64(1, "Y"), (12, 0, 0, 0)), + (np.timedelta64(1, "M"), (1, 0, 0, 0)), + (np.timedelta64(1, "D"), (0, 1, 0, 0)), + (np.timedelta64(1, "h"), (0, 0, 3600, 0)), + (np.timedelta64(1, "m"), (0, 0, 60, 0)), + (np.timedelta64(1, "s"), (0, 0, 1, 0)), + (np.timedelta64(MAX_INT64, "s"), (0, 0, MAX_INT64, 0)), + (np.timedelta64(1, "ms"), (0, 0, 0, 1000000)), + (np.timedelta64(1, "us"), (0, 0, 0, 1000)), + (np.timedelta64(1, "ns"), (0, 0, 0, 1)), + (np.timedelta64(NANO_SECONDS, "ns"), (0, 0, 1, 0)), + (np.timedelta64(NANO_SECONDS + 1, "ns"), (0, 0, 1, 1)), + (np.timedelta64(1000, "ps"), (0, 0, 0, 1)), + (np.timedelta64(1, "ps"), (0, 0, 0, 0)), + (np.timedelta64(1000000, "fs"), (0, 0, 0, 1)), + (np.timedelta64(1, "fs"), (0, 0, 0, 0)), + (np.timedelta64(1000000000, "as"), (0, 0, 0, 1)), + (np.timedelta64(1, "as"), (0, 0, 0, 0)), + (np.timedelta64(-1, "Y"), (-12, 0, 0, 0)), + (np.timedelta64(-1, "M"), (-1, 0, 0, 0)), + (np.timedelta64(-1, "D"), (0, -1, 0, 0)), + (np.timedelta64(-1, "h"), (0, 0, -3600, 0)), + (np.timedelta64(-1, "m"), (0, 0, -60, 0)), + (np.timedelta64(-1, "s"), (0, 0, -1, 0)), + # numpy uses MIN_INT64 to encode NaT + (np.timedelta64(MIN_INT64 + 1, "s"), (0, 0, MIN_INT64 + 1, 0)), + (np.timedelta64(-1, "ms"), (0, 0, 0, -1000000)), + (np.timedelta64(-1, "us"), (0, 0, 0, -1000)), + (np.timedelta64(-1, "ns"), (0, 0, 0, -1)), + (np.timedelta64(-NANO_SECONDS, "ns"), (0, 0, -1, 0)), + (np.timedelta64(-NANO_SECONDS - 1, "ns"), (0, 0, -1, -1)), + (np.timedelta64(-1000, "ps"), (0, 0, 0, -1)), + (np.timedelta64(-1, "ps"), (0, 0, 0, -1)), + (np.timedelta64(-1000000, "fs"), (0, 0, 0, -1)), + (np.timedelta64(-1, "fs"), (0, 0, 0, -1)), + (np.timedelta64(-1000000000, "as"), (0, 0, 0, -1)), + (np.timedelta64(-1, "as"), (0, 0, 0, -1)), ) - TestTimeDehydrationV2().test_native_date_time_zone_id( - hydration_scope + ) + def test_numpy_duration(self, value, expected_fields, assert_transforms): + assert_transforms(value, Structure(b"E", *expected_fields)) + + def test_numpy_nat_duration(self, assert_transforms): + duration = np.timedelta64("NaT") + assert_transforms(duration, None) + + @pytest.mark.parametrize(("value", "error"), ( + (np.timedelta64((MAX_INT64 // 60) + 1, "m"), ValueError), + (np.timedelta64((MIN_INT64 // 60), "m"), ValueError), + + )) + def test_numpy_invalid_durations(self, value, error, transformer): + with pytest.raises(error): + transformer(value) + + @pytest.mark.parametrize( + ("value", "expected_fields"), + ( + ( + pd.Timedelta(days=1, seconds=2, microseconds=3, nanoseconds=4), + (0, 0, AVERAGE_SECONDS_IN_DAY + 2, 3004) + ), + ( + pd.Timedelta(days=-1, seconds=2, microseconds=3, + nanoseconds=4), + (0, 0, -AVERAGE_SECONDS_IN_DAY + 2 + 1, -NANO_SECONDS + 3004) + ) ) + ) + def test_pandas_duration(self, value, expected_fields, assert_transforms): + assert_transforms(value, Structure(b"E", *expected_fields)) diff --git a/tests/unit/common/codec/hydration/v1/test_temporal_dehydration_utc_patch.py b/tests/unit/common/codec/hydration/v1/test_temporal_dehydration_utc_patch.py new file mode 100644 index 000000000..66ba4b0f7 --- /dev/null +++ b/tests/unit/common/codec/hydration/v1/test_temporal_dehydration_utc_patch.py @@ -0,0 +1,61 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pytest + +from ..v2.test_temporal_dehydration import ( + TestTimeDehydration as _TestTimeDehydrationV2, +) +from .test_temporal_dehydration import ( + HydrationHandler, # testing the same hydration handler +) +from .test_temporal_dehydration import ( + TestTimeDehydration as _TestTimeDehydrationV1, +) + + +class UTCPatchedTimeDehydrationMeta(type): + def __new__(mcs, name, bases, attrs): + for test_func in ( + "test_date_time_fixed_offset", + "test_native_date_time_fixed_offset", + "test_pandas_date_time_fixed_offset", + "test_date_time_fixed_negative_offset", + "test_native_date_time_fixed_negative_offset", + "test_pandas_date_time_fixed_negative_offset", + "test_date_time_zone_id", + "test_native_date_time_zone_id", + "test_pandas_date_time_zone_id", + ): + if not hasattr(_TestTimeDehydrationV2, test_func): + continue + attrs[test_func] = getattr(_TestTimeDehydrationV2, test_func) + + return super(UTCPatchedTimeDehydrationMeta, mcs).__new__( + mcs, name, bases, attrs + ) + + +class TestUTCPatchedTimeDehydration( + _TestTimeDehydrationV1, metaclass=UTCPatchedTimeDehydrationMeta +): + @pytest.fixture + def hydration_handler(self): + handler = HydrationHandler() + handler.patch_utc() + return handler diff --git a/tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py b/tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py index 97074e3c7..7cad75d57 100644 --- a/tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py +++ b/tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py @@ -18,6 +18,7 @@ import datetime +import pandas as pd import pytest import pytz @@ -35,42 +36,82 @@ class TestTimeDehydration(_TestTemporalDehydrationV1): def hydration_handler(self): return HydrationHandler() - def test_date_time(self, hydration_scope): + def test_date_time_fixed_offset(self, assert_transforms): dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, pytz.FixedOffset(60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"I", 1539340661, 474716862, 3600) + assert_transforms( + dt, + Structure(b"I", 1539340661, 474716862, 3600) + ) - def test_native_date_time(self, hydration_scope): + def test_native_date_time_fixed_offset(self, assert_transforms): dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, pytz.FixedOffset(60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"I", 1539340661, 474716000, 3600) + assert_transforms( + dt, + Structure(b"I", 1539340661, 474716000, 3600) + ) - def test_date_time_negative_offset(self, hydration_scope): + def test_pandas_date_time_fixed_offset(self, assert_transforms): + dt = pd.Timestamp("2018-10-12T11:37:41.474716862+0100") + assert_transforms(dt, Structure(b"I", 1539340661, 474716862, 3600)) + + def test_date_time_fixed_negative_offset(self, assert_transforms): dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, pytz.FixedOffset(-60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"I", 1539347861, 474716862, -3600) + assert_transforms( + dt, + Structure(b"I", 1539347861, 474716862, -3600) + ) - def test_native_date_time_negative_offset(self, hydration_scope): + def test_native_date_time_fixed_negative_offset(self, assert_transforms): dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, pytz.FixedOffset(-60)) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"I", 1539347861, 474716000, -3600) + assert_transforms( + dt, + Structure(b"I", 1539347861, 474716000, -3600) + ) + + def test_pandas_date_time_fixed_negative_offset(self, assert_transforms): + dt = pd.Timestamp("2018-10-12T11:37:41.474716862-0100") + assert_transforms(dt, Structure(b"I", 1539347861, 474716862, -3600)) - def test_date_time_zone_id(self, hydration_scope): + def test_date_time_zone_id(self, assert_transforms): dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862) dt = pytz.timezone("Europe/Stockholm").localize(dt) # offset should be UTC+2 (7200 seconds) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"i", 1539337061, 474716862, - "Europe/Stockholm") + assert_transforms( + dt, + Structure(b"i", 1539337061, 474716862, "Europe/Stockholm") + ) - def test_native_date_time_zone_id(self, hydration_scope): + def test_native_date_time_zone_id(self, assert_transforms): dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716) dt = pytz.timezone("Europe/Stockholm").localize(dt) # offset should be UTC+2 (7200 seconds) - struct = hydration_scope.dehydration_hooks[type(dt)](dt) - assert struct == Structure(b"i", 1539337061, 474716000, - "Europe/Stockholm") + assert_transforms( + dt, + Structure(b"i", 1539337061, 474716000, "Europe/Stockholm") + ) + + @pytest.mark.parametrize(("dt", "fields"), ( + ( + pd.Timestamp("2018-10-12T11:37:41.474716862+0200", + tz="Europe/Stockholm"), + (1539337061, 474716862, "Europe/Stockholm"), + ), + ( + # 1972-10-29 02:00:01.001000001+0100 pre DST change + pd.Timestamp((1032 * 24 + 2) * 3600 * 1000000000 + 1001000001, + tz="Europe/London"), + ((1032 * 24 + 2) * 3600 + 1, 1000001, "Europe/London"), + ), + ( + # 1972-10-29 02:00:01.001000001+0000 post DST change + pd.Timestamp((1032 * 24 + 1) * 3600 * 1000000000 + 1001000001, + tz="Europe/London"), + ((1032 * 24 + 1) * 3600 + 1, 1000001, "Europe/London"), + ) + )) + def test_pandas_date_time_zone_id(self, dt, fields, assert_transforms): + assert_transforms(dt, Structure(b"i", *fields)) diff --git a/tests/unit/common/codec/packstream/v1/test_packstream.py b/tests/unit/common/codec/packstream/v1/test_packstream.py index 14f8fcfb5..fa81637b2 100644 --- a/tests/unit/common/codec/packstream/v1/test_packstream.py +++ b/tests/unit/common/codec/packstream/v1/test_packstream.py @@ -18,9 +18,14 @@ import struct from io import BytesIO -from math import pi +from math import ( + isnan, + pi, +) from uuid import uuid4 +import numpy as np +import pandas as pd import pytest from neo4j._codec.packstream import Structure @@ -36,227 +41,427 @@ not_ascii = "♥O◘♦♥O◘♦" -class TestPackStream: - @pytest.fixture - def packer_with_buffer(self): - packable_buffer = Packer.new_packable_buffer() - return Packer(packable_buffer), packable_buffer +@pytest.fixture +def packer_with_buffer(): + packable_buffer = Packer.new_packable_buffer() + return Packer(packable_buffer), packable_buffer - @pytest.fixture - def unpacker_with_buffer(self): - unpackable_buffer = Unpacker.new_unpackable_buffer() - return Unpacker(unpackable_buffer), unpackable_buffer - def test_packable_buffer(self, packer_with_buffer): - packer, packable_buffer = packer_with_buffer - assert isinstance(packable_buffer, PackableBuffer) - assert packable_buffer is packer.stream +@pytest.fixture +def unpacker_with_buffer(): + unpackable_buffer = Unpacker.new_unpackable_buffer() + return Unpacker(unpackable_buffer), unpackable_buffer - def test_unpackable_buffer(self, unpacker_with_buffer): - unpacker, unpackable_buffer = unpacker_with_buffer - assert isinstance(unpackable_buffer, UnpackableBuffer) - assert unpackable_buffer is unpacker.unpackable +def test_packable_buffer(packer_with_buffer): + packer, packable_buffer = packer_with_buffer + assert isinstance(packable_buffer, PackableBuffer) + assert packable_buffer is packer.stream + +def test_unpackable_buffer(unpacker_with_buffer): + unpacker, unpackable_buffer = unpacker_with_buffer + assert isinstance(unpackable_buffer, UnpackableBuffer) + assert unpackable_buffer is unpacker.unpackable + + +@pytest.fixture +def pack(packer_with_buffer): + packer, packable_buffer = packer_with_buffer - @pytest.fixture - def pack(self, packer_with_buffer): + def _pack(*values, dehydration_hooks=None): + for value in values: + packer.pack(value, dehydration_hooks=dehydration_hooks) + data = bytearray(packable_buffer.data) + packable_buffer.clear() + return data + + return _pack + + +_default_out_value = object() + + +@pytest.fixture +def assert_packable(packer_with_buffer, unpacker_with_buffer): + def _recursive_nan_equal(a, b): + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + return all(_recursive_nan_equal(x, y) for x, y in zip(a, b)) + elif isinstance(a, dict) and isinstance(b, dict): + return all(_recursive_nan_equal(a[k], b[k]) for k in a) + else: + return a == b or (isnan(a) and isnan(b)) + + def _assert(in_value, packed_value, out_value=_default_out_value): + if out_value is _default_out_value: + out_value = in_value + nonlocal packer_with_buffer, unpacker_with_buffer packer, packable_buffer = packer_with_buffer + unpacker, unpackable_buffer = unpacker_with_buffer + packable_buffer.clear() + unpackable_buffer.reset() - def _pack(*values, dehydration_hooks=None): - for value in values: - packer.pack(value, dehydration_hooks=dehydration_hooks) - data = bytearray(packable_buffer.data) - packable_buffer.clear() - return data + packer.pack(in_value) + packed_data = packable_buffer.data + assert packed_data == packed_value - return _pack + unpackable_buffer.data = bytearray(packed_data) + unpackable_buffer.used = len(packed_data) + unpacked_data = unpacker.unpack() + assert _recursive_nan_equal(unpacked_data, out_value) - @pytest.fixture - def assert_packable(self, packer_with_buffer, unpacker_with_buffer): - def _assert(value, packed_value): - nonlocal packer_with_buffer, unpacker_with_buffer - packer, packable_buffer = packer_with_buffer - unpacker, unpackable_buffer = unpacker_with_buffer - packable_buffer.clear() - unpackable_buffer.reset() + return _assert - packer.pack(value) - packed_data = packable_buffer.data - assert packed_data == packed_value - unpackable_buffer.data = bytearray(packed_data) - unpackable_buffer.used = len(packed_data) - unpacked_data = unpacker.unpack() - assert unpacked_data == value +@pytest.fixture(params=( + int, + np.int8, np.int16, np.int32, np.int64, np.longlong, + np.uint8, np.uint16, np.uint32, np.uint64, np.ulonglong +)) +def int_type(request): + return request.param - return _assert - def test_none(self, assert_packable): - assert_packable(None, b"\xC0") +@pytest.fixture(params=(float, + np.float16, np.float32, np.float64, np.longdouble)) +def float_type(request): + return request.param - def test_boolean(self, assert_packable): - assert_packable(True, b"\xC3") - assert_packable(False, b"\xC2") - def test_negative_tiny_int(self, assert_packable): +@pytest.fixture(params=(bool, np.bool_)) +def bool_type(request): + return request.param + + +@pytest.fixture(params=(bytes, bytearray, np.bytes_)) +def bytes_type(request): + return request.param + + +@pytest.fixture(params=(str, np.str_)) +def str_type(request): + return request.param + + +@pytest.fixture(params=(list, tuple, np.array, + pd.Series, pd.array, pd.arrays.SparseArray)) +def sequence_type(request): + if request.param is pd.Series: + def constructor(value): + if not value: + return pd.Series(dtype=object) + return pd.Series(value) + + return constructor + return request.param + + +class TestPackStream: + @pytest.mark.parametrize("value", (None, pd.NA)) + def test_none(self, value, assert_packable): + assert_packable(value, b"\xC0", None) + + def test_boolean(self, bool_type, assert_packable): + assert_packable(bool_type(True), b"\xC3") + assert_packable(bool_type(False), b"\xC2") + + @pytest.mark.parametrize("dtype", (bool, pd.BooleanDtype())) + def test_boolean_pandas_series(self, dtype, assert_packable): + value = [True, False] + value_series = pd.Series(value, dtype=dtype) + assert_packable(value_series, b"\x92\xC3\xC2", value) + + def test_negative_tiny_int(self, int_type, assert_packable): + for z in range(-16, 0): + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable + assert_packable(z_typed, bytes(bytearray([z + 0x100]))) + + @pytest.mark.parametrize("dtype", ( + int, pd.Int8Dtype(), pd.Int16Dtype(), pd.Int32Dtype(), pd.Int64Dtype(), + np.int8, np.int16, np.int32, np.int64, np.longlong, + )) + def test_negative_tiny_int_pandas_series(self, dtype, assert_packable): for z in range(-16, 0): - assert_packable(z, bytes(bytearray([z + 0x100]))) + z_typed = pd.Series(z, dtype=dtype) + if z != int(z_typed): + continue # not representable + assert_packable(z_typed, bytes(bytearray([0x91, z + 0x100])), [z]) - def test_positive_tiny_int(self, assert_packable): + def test_positive_tiny_int(self, int_type, assert_packable): for z in range(0, 128): - assert_packable(z, bytes(bytearray([z]))) + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable + assert_packable(z_typed, bytes(bytearray([z]))) - def test_negative_int8(self, assert_packable): + def test_negative_int8(self, int_type, assert_packable): for z in range(-128, -16): - assert_packable(z, bytes(bytearray([0xC8, z + 0x100]))) + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable + assert_packable(z_typed, bytes(bytearray([0xC8, z + 0x100]))) - def test_positive_int16(self, assert_packable): + def test_positive_int16(self, int_type, assert_packable): for z in range(128, 32768): + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable expected = b"\xC9" + struct.pack(">h", z) - assert_packable(z, expected) + assert_packable(z_typed, expected) - def test_negative_int16(self, assert_packable): + def test_negative_int16(self, int_type, assert_packable): for z in range(-32768, -128): + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable expected = b"\xC9" + struct.pack(">h", z) - assert_packable(z, expected) + assert_packable(z_typed, expected) - def test_positive_int32(self, assert_packable): + def test_positive_int32(self, int_type, assert_packable): for e in range(15, 31): z = 2 ** e + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable expected = b"\xCA" + struct.pack(">i", z) - assert_packable(z, expected) + assert_packable(z_typed, expected) - def test_negative_int32(self, assert_packable): + def test_negative_int32(self, int_type, assert_packable): for e in range(15, 31): z = -(2 ** e + 1) + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable expected = b"\xCA" + struct.pack(">i", z) - assert_packable(z, expected) + assert_packable(z_typed, expected) - def test_positive_int64(self, assert_packable): + def test_positive_int64(self, int_type, assert_packable): for e in range(31, 63): z = 2 ** e + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable expected = b"\xCB" + struct.pack(">q", z) - assert_packable(z, expected) + assert_packable(z_typed, expected) - def test_negative_int64(self, assert_packable): + @pytest.mark.parametrize("dtype", ( + int, pd.Int64Dtype(), pd.UInt64Dtype(), + np.int64, np.longlong, np.uint64, np.ulonglong, + )) + def test_positive_int64_pandas_series(self, dtype, assert_packable): + for e in range(31, 63): + z = 2 ** e + z_typed = pd.Series(z, dtype=dtype) + expected = b"\x91\xCB" + struct.pack(">q", z) + assert_packable(z_typed, expected, [z]) + + def test_negative_int64(self, int_type, assert_packable): for e in range(31, 63): z = -(2 ** e + 1) + z_typed = int_type(z) + if z != int(z_typed): + continue # not representable expected = b"\xCB" + struct.pack(">q", z) - assert_packable(z, expected) + assert_packable(z_typed, expected) - def test_integer_positive_overflow(self, pack, assert_packable): - with pytest.raises(OverflowError): - pack(2 ** 63 + 1) + @pytest.mark.parametrize("dtype", ( + int, pd.Int64Dtype(), np.int64, np.longlong, + )) + def test_negative_int64_pandas_series(self, dtype, assert_packable): + for e in range(31, 63): + z = -(2 ** e + 1) + z_typed = pd.Series(z, dtype=dtype) + expected = b"\x91\xCB" + struct.pack(">q", z) + assert_packable(z_typed, expected, [z]) - def test_integer_negative_overflow(self, pack, assert_packable): + def test_integer_positive_overflow(self, int_type, pack, assert_packable): with pytest.raises(OverflowError): - pack(-(2 ** 63) - 1) - - def test_zero_float64(self, assert_packable): - zero = 0.0 - expected = b"\xC1" + struct.pack(">d", zero) - assert_packable(zero, expected) - - def test_tau_float64(self, assert_packable): - tau = 2 * pi - expected = b"\xC1" + struct.pack(">d", tau) - assert_packable(tau, expected) - - def test_positive_float64(self, assert_packable): - for e in range(0, 100): - r = float(2 ** e) + 0.5 - expected = b"\xC1" + struct.pack(">d", r) - assert_packable(r, expected) - - def test_negative_float64(self, assert_packable): - for e in range(0, 100): - r = -(float(2 ** e) + 0.5) - expected = b"\xC1" + struct.pack(">d", r) - assert_packable(r, expected) - - def test_empty_bytes(self, assert_packable): - assert_packable(b"", b"\xCC\x00") + z = 2 ** 63 + 1 + z_typed = int_type(z) + if z != int(z_typed): + pytest.skip("not representable") + pack(z_typed) - def test_empty_bytearray(self, assert_packable): - assert_packable(bytearray(), b"\xCC\x00") - - def test_bytes_8(self, assert_packable): - assert_packable(bytearray(b"hello"), b"\xCC\x05hello") - - def test_bytes_16(self, assert_packable): + def test_integer_negative_overflow(self, int_type, pack, assert_packable): + with pytest.raises(OverflowError): + z = -(2 ** 63) - 1 + z_typed = int_type(z) + if z != int(z_typed): + pytest.skip("not representable") + pack(z_typed) + + def test_float(self, float_type, assert_packable): + for z in ( + 0.0, -0.0, pi, 2 * pi, float("inf"), float("-inf"), float("nan"), + *(float(2 ** e) + 0.5 for e in range(100)), + *(-float(2 ** e) + 0.5 for e in range(100)), + ): + z_typed = float_type(z) + expected = b"\xC1" + struct.pack(">d", float(z_typed)) + assert_packable(z_typed, expected) + + @pytest.mark.parametrize("dtype", ( + float, pd.Float32Dtype(), pd.Float64Dtype(), + np.float16, np.float32, np.float64, np.longdouble, + )) + def test_float_pandas_series(self, dtype, assert_packable): + for z in ( + 0.0, -0.0, pi, 2 * pi, float("inf"), float("-inf"), float("nan"), + *(float(2 ** e) + 0.5 for e in range(100)), + *(-float(2 ** e) + 0.5 for e in range(100)), + ): + z_typed = pd.Series(z, dtype=dtype) + if z_typed[0] is pd.NA: + expected_bytes = b"\x91\xC0" # encoded as NULL + expected_value = [None] + else: + expected_bytes = (b"\x91\xC1" + + struct.pack(">d", float(z_typed[0]))) + expected_value = [float(z_typed[0])] + assert_packable(z_typed, expected_bytes, expected_value) + + def test_empty_bytes(self, bytes_type, assert_packable): + b = bytes_type(b"") + assert_packable(b, b"\xCC\x00") + + def test_bytes_8(self, bytes_type, assert_packable): + b = bytes_type(b"hello") + assert_packable(b, b"\xCC\x05hello") + + def test_bytes_16(self, bytes_type, assert_packable): b = bytearray(40000) - assert_packable(b, b"\xCD\x9C\x40" + b) + b_typed = bytes_type(b) + assert_packable(b_typed, b"\xCD\x9C\x40" + b) - def test_bytes_32(self, assert_packable): + def test_bytes_32(self, bytes_type, assert_packable): b = bytearray(80000) - assert_packable(b, b"\xCE\x00\x01\x38\x80" + b) - - def test_bytearray_size_overflow(self, assert_packable): + b_typed = bytes_type(b) + assert_packable(b_typed, b"\xCE\x00\x01\x38\x80" + b) + + def test_bytes_pandas_series(self, assert_packable): + for b, header in ( + (b"", b"\xCC\x00"), + (b"hello", b"\xCC\x05"), + (bytearray(40000), b"\xCD\x9C\x40"), + (bytearray(80000), b"\xCE\x00\x01\x38\x80"), + ): + b_typed = pd.Series([b]) + assert_packable(b_typed, b"\x91" + header + b, [b]) + + def test_bytearray_size_overflow(self, bytes_type, assert_packable): stream_out = BytesIO() packer = Packer(stream_out) with pytest.raises(OverflowError): - packer.pack_bytes_header(2 ** 32) + packer._pack_bytes_header(2 ** 32) - def test_empty_string(self, assert_packable): - assert_packable(u"", b"\x80") + def test_empty_string(self, str_type, assert_packable): + assert_packable(str_type(""), b"\x80") - def test_tiny_strings(self, assert_packable): + def test_tiny_strings(self, str_type, assert_packable): for size in range(0x10): - assert_packable(u"A" * size, bytes(bytearray([0x80 + size]) + (b"A" * size))) + s = str_type("A" * size) + assert_packable(s, bytes(bytearray([0x80 + size]) + (b"A" * size))) - def test_string_8(self, assert_packable): - t = u"A" * 40 + def test_string_8(self, str_type, assert_packable): + t = "A" * 40 b = t.encode("utf-8") - assert_packable(t, b"\xD0\x28" + b) + t_typed = str_type(t) + assert_packable(t_typed, b"\xD0\x28" + b) - def test_string_16(self, assert_packable): - t = u"A" * 40000 + def test_string_16(self, str_type, assert_packable): + t = "A" * 40000 b = t.encode("utf-8") - assert_packable(t, b"\xD1\x9C\x40" + b) + t_typed = str_type(t) + assert_packable(t_typed, b"\xD1\x9C\x40" + b) - def test_string_32(self, assert_packable): - t = u"A" * 80000 + def test_string_32(self, str_type, assert_packable): + t = "A" * 80000 b = t.encode("utf-8") - assert_packable(t, b"\xD2\x00\x01\x38\x80" + b) + t_typed = str_type(t) + assert_packable(t_typed, b"\xD2\x00\x01\x38\x80" + b) - def test_unicode_string(self, assert_packable): - t = u"héllö" + def test_unicode_string(self, str_type, assert_packable): + t = "héllö" b = t.encode("utf-8") - assert_packable(t, bytes(bytearray([0x80 + len(b)])) + b) + t_typed = str_type(t) + assert_packable(t_typed, bytes(bytearray([0x80 + len(b)])) + b) + + @pytest.mark.parametrize("dtype", ( + str, np.str_, pd.StringDtype("python"), pd.StringDtype("pyarrow"), + )) + def test_string_pandas_series(self, dtype, assert_packable): + values = ( + ("", b"\x80"), + ("A" * 40, b"\xD0\x28"), + ("A" * 40000, b"\xD1\x9C\x40"), + ("A" * 80000, b"\xD2\x00\x01\x38\x80"), + ) + for t, header in values: + t_typed = pd.Series([t], dtype=dtype) + assert_packable(t_typed, b"\x91" + header + t.encode("utf-8"), [t]) + + t_typed = pd.Series([t for t, _ in values], dtype=dtype) + expected = ( + bytes([0x90 + len(values)]) + + b"".join(header + t.encode("utf-8") for t, header in values) + ) + assert_packable(t_typed, expected, [t for t, _ in values]) def test_string_size_overflow(self): stream_out = BytesIO() packer = Packer(stream_out) with pytest.raises(OverflowError): - packer.pack_string_header(2 ** 32) + packer._pack_string_header(2 ** 32) - def test_empty_list(self, assert_packable): - assert_packable([], b"\x90") + def test_empty_list(self, sequence_type, assert_packable): + l = [] + l_typed = sequence_type(l) + assert_packable(l_typed, b"\x90", l) - def test_tiny_lists(self, assert_packable): + def test_tiny_lists(self, sequence_type, assert_packable): for size in range(0x10): + l = [1] * size + l_typed = sequence_type(l) data_out = bytearray([0x90 + size]) + bytearray([1] * size) - assert_packable([1] * size, bytes(data_out)) + assert_packable(l_typed, bytes(data_out), l) - def test_list_8(self, assert_packable): + def test_list_8(self, sequence_type, assert_packable): l = [1] * 40 - assert_packable(l, b"\xD4\x28" + (b"\x01" * 40)) + l_typed = sequence_type(l) + assert_packable(l_typed, b"\xD4\x28" + (b"\x01" * 40), l) - def test_list_16(self, assert_packable): + def test_list_16(self, sequence_type, assert_packable): l = [1] * 40000 - assert_packable(l, b"\xD5\x9C\x40" + (b"\x01" * 40000)) + l_typed = sequence_type(l) + assert_packable(l_typed, b"\xD5\x9C\x40" + (b"\x01" * 40000), l) - def test_list_32(self, assert_packable): + def test_list_32(self, sequence_type, assert_packable): l = [1] * 80000 - assert_packable(l, b"\xD6\x00\x01\x38\x80" + (b"\x01" * 80000)) - - def test_nested_lists(self, assert_packable): - assert_packable([[[]]], b"\x91\x91\x90") + l_typed = sequence_type(l) + assert_packable(l_typed, b"\xD6\x00\x01\x38\x80" + (b"\x01" * 80000), l) + + def test_nested_lists(self, sequence_type, assert_packable): + l = [[[]]] + l_typed = sequence_type([sequence_type([sequence_type([])])]) + assert_packable(l_typed, b"\x91\x91\x90", l) + + @pytest.mark.parametrize("as_series", (True, False)) + def test_list_pandas_categorical(self, as_series, pack, assert_packable): + l = ["cat", "dog", "cat", "cat", "dog", "horse"] + l_typed = pd.Categorical(l) + if as_series: + l_typed = pd.Series(l_typed) + b = b"".join([ + b"\x96", + *(pack(e) for e in l) + ]) + assert_packable(l_typed, b, l) def test_list_size_overflow(self): stream_out = BytesIO() packer = Packer(stream_out) with pytest.raises(OverflowError): - packer.pack_list_header(2 ** 32) + packer._pack_list_header(2 ** 32) def test_empty_map(self, assert_packable): assert_packable({}, b"\xA0") @@ -285,14 +490,30 @@ def test_map_32(self, pack, assert_packable): b = b"".join(pack(u"A%s" % i, 1) for i in range(80000)) assert_packable(d, b"\xDA\x00\x01\x38\x80" + b) + def test_empty_dataframe_maps(self, assert_packable): + df = pd.DataFrame() + assert_packable(df, b"\xA0", {}) + + @pytest.mark.parametrize("size", range(0x10)) + def test_tiny_dataframes_maps(self, assert_packable, size): + data_in = dict() + data_out = bytearray([0xA0 + size]) + for el in range(1, size + 1): + data_in[chr(64 + el)] = [el] + data_out += bytearray([0x81, 64 + el, 0x91, el]) + data_in_typed = pd.DataFrame(data_in) + assert_packable(data_in_typed, bytes(data_out), data_in) + def test_map_size_overflow(self): stream_out = BytesIO() packer = Packer(stream_out) with pytest.raises(OverflowError): - packer.pack_map_header(2 ** 32) + packer._pack_map_header(2 ** 32) @pytest.mark.parametrize(("map_", "exc_type"), ( ({1: "1"}, TypeError), + (pd.DataFrame({1: ["1"]}), TypeError), + (pd.DataFrame({(1, 2): ["1"]}), TypeError), ({"x": {1: 'eins', 2: 'zwei', 3: 'drei'}}, TypeError), ({"x": {(1, 2): '1+2i', (2, 0): '2'}}, TypeError), )) From 6fb939a5fc03352097b3b94c5a57797944abe80a Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Tue, 3 Jan 2023 09:20:32 +0100 Subject: [PATCH 2/7] Fix serializing spatial types as lists --- src/neo4j/_codec/packstream/v1/__init__.py | 40 +++++++++++++++---- .../codec/packstream/v1/test_packstream.py | 2 +- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/neo4j/_codec/packstream/v1/__init__.py b/src/neo4j/_codec/packstream/v1/__init__.py index efd3db74c..abf30a70a 100644 --- a/src/neo4j/_codec/packstream/v1/__init__.py +++ b/src/neo4j/_codec/packstream/v1/__init__.py @@ -24,6 +24,7 @@ unpack as struct_unpack, ) +from ...hydration import DehydrationHooks from .._common import Structure @@ -32,7 +33,9 @@ FALSE_VALUES: t.Tuple = (False,) INT_TYPES: t.Tuple[t.Type, ...] = (int,) FLOAT_TYPES: t.Tuple[t.Type, ...] = (float,) -SEQUENCE_TYPES: t.Tuple[t.Type, ...] = (list, tuple) +# we can't put tuple here because spatial types subclass tuple, +# and we don't want to treat them as sequences +SEQUENCE_TYPES: t.Tuple[t.Type, ...] = (list,) MAPPING_TYPES: t.Tuple[t.Type, ...] = (dict,) BYTES_TYPES: t.Tuple[t.Type, ...] = (bytes, bytearray) @@ -81,7 +84,24 @@ def __init__(self, stream): def _pack_raw(self, data): self._write(data) - def pack(self, value, dehydration_hooks=None): + def pack(self, data, dehydration_hooks=None): + self._pack(data, + dehydration_hooks=self._inject_hooks(dehydration_hooks)) + + @classmethod + def _inject_hooks(cls, dehydration_hooks=None): + if dehydration_hooks is None: + return DehydrationHooks( + exact_types={tuple: list}, + subtypes={} + ) + return dehydration_hooks.extend( + exact_types={tuple: list}, + subtypes={} + ) + + + def _pack(self, value, dehydration_hooks=None): write = self._write # None @@ -133,7 +153,7 @@ def pack(self, value, dehydration_hooks=None): elif isinstance(value, SEQUENCE_TYPES): self._pack_list_header(len(value)) for item in value: - self.pack(item, dehydration_hooks) + self._pack(item, dehydration_hooks) # Map elif isinstance(value, MAPPING_TYPES): @@ -143,8 +163,8 @@ def pack(self, value, dehydration_hooks=None): raise TypeError( "Map keys must be strings, not {}".format(type(key)) ) - self.pack(key, dehydration_hooks) - self.pack(item, dehydration_hooks) + self._pack(key, dehydration_hooks) + self._pack(item, dehydration_hooks) # Structure elif isinstance(value, Structure): @@ -155,7 +175,7 @@ def pack(self, value, dehydration_hooks=None): if dehydration_hooks: transformer = dehydration_hooks.get_transformer(value) if transformer is not None: - self.pack(transformer(value), dehydration_hooks) + self._pack(transformer(value), dehydration_hooks) return raise ValueError("Values of type %s are not supported" % type(value)) @@ -223,6 +243,12 @@ def _pack_map_header(self, size): raise OverflowError("Map header size out of range") def pack_struct(self, signature, fields, dehydration_hooks=None): + self._pack_struct( + signature, fields, + dehydration_hooks=self._inject_hooks(dehydration_hooks) + ) + + def _pack_struct(self, signature, fields, dehydration_hooks=None): if len(signature) != 1 or not isinstance(signature, bytes): raise ValueError("Structure signature must be a single byte value") write = self._write @@ -233,7 +259,7 @@ def pack_struct(self, signature, fields, dehydration_hooks=None): raise OverflowError("Structure size out of range") write(signature) for field in fields: - self.pack(field, dehydration_hooks) + self._pack(field, dehydration_hooks) @staticmethod def new_packable_buffer(): diff --git a/tests/unit/common/codec/packstream/v1/test_packstream.py b/tests/unit/common/codec/packstream/v1/test_packstream.py index fa81637b2..1d995c45e 100644 --- a/tests/unit/common/codec/packstream/v1/test_packstream.py +++ b/tests/unit/common/codec/packstream/v1/test_packstream.py @@ -521,7 +521,7 @@ def test_map_key_type(self, packer_with_buffer, map_, exc_type): # maps must have string keys packer, packable_buffer = packer_with_buffer with pytest.raises(exc_type, match="strings"): - packer.pack(map_) + packer._pack(map_) def test_illegal_signature(self, assert_packable): with pytest.raises(ValueError): From 61470046277c417aebe64e89c855e473cb4bba4b Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Tue, 3 Jan 2023 12:20:09 +0100 Subject: [PATCH 3/7] Fix NEP50 (numpy checking for more overflows) --- requirements-dev.txt | 2 +- src/neo4j/_codec/packstream/v1/__init__.py | 7 +- .../codec/packstream/v1/test_packstream.py | 95 +++++++++++++------ 3 files changed, 69 insertions(+), 35 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index b070002c1..eb05e75c2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,7 @@ tomlkit~=0.11.6 # needed for running tests coverage[toml]>=5.5 mock>=4.0.3 -numpy>=1.7.0 +numpy>=1.24.0 # using numpy._set_promotion_state https://numpy.org/neps/nep-0050-scalar-promotion.html pandas>=1.0.0 pyarrow>=1.0.0 pytest>=6.2.5 diff --git a/src/neo4j/_codec/packstream/v1/__init__.py b/src/neo4j/_codec/packstream/v1/__init__.py index abf30a70a..1e5586e60 100644 --- a/src/neo4j/_codec/packstream/v1/__init__.py +++ b/src/neo4j/_codec/packstream/v1/__init__.py @@ -121,14 +121,15 @@ def _pack(self, value, dehydration_hooks=None): # Integer elif isinstance(value, INT_TYPES): + value = int(value) if -0x10 <= value < 0x80: - write(PACKED_UINT_8[int(value % 0x100)]) + write(PACKED_UINT_8[value % 0x100]) elif -0x80 <= value < -0x10: write(b"\xC8") - write(PACKED_UINT_8[int(value % 0x100)]) + write(PACKED_UINT_8[value % 0x100]) elif -0x8000 <= value < 0x8000: write(b"\xC9") - write(PACKED_UINT_16[int(value % 0x10000)]) + write(PACKED_UINT_16[value % 0x10000]) elif -0x80000000 <= value < 0x80000000: write(b"\xCA") write(struct_pack(">i", value)) diff --git a/tests/unit/common/codec/packstream/v1/test_packstream.py b/tests/unit/common/codec/packstream/v1/test_packstream.py index 1d995c45e..a7f4eed47 100644 --- a/tests/unit/common/codec/packstream/v1/test_packstream.py +++ b/tests/unit/common/codec/packstream/v1/test_packstream.py @@ -111,19 +111,35 @@ def _assert(in_value, packed_value, out_value=_default_out_value): return _assert +@pytest.fixture +def np_nep50_as_error(): + nep50_promotion_state = np._get_promotion_state() + np._set_promotion_state("weak") + yield + np._set_promotion_state(nep50_promotion_state) + + +@pytest.fixture +def np_float_overflow_as_error(): + old_err = np.seterr(over="raise") + yield + np.seterr(**old_err) + + + @pytest.fixture(params=( int, np.int8, np.int16, np.int32, np.int64, np.longlong, np.uint8, np.uint16, np.uint32, np.uint64, np.ulonglong )) -def int_type(request): - return request.param +def int_type(request, np_nep50_as_error): + yield request.param @pytest.fixture(params=(float, np.float16, np.float32, np.float64, np.longdouble)) -def float_type(request): - return request.param +def float_type(request, np_float_overflow_as_error): + yield request.param @pytest.fixture(params=(bool, np.bool_)) @@ -171,8 +187,9 @@ def test_boolean_pandas_series(self, dtype, assert_packable): def test_negative_tiny_int(self, int_type, assert_packable): for z in range(-16, 0): - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable assert_packable(z_typed, bytes(bytearray([z + 0x100]))) @@ -183,36 +200,38 @@ def test_negative_tiny_int(self, int_type, assert_packable): def test_negative_tiny_int_pandas_series(self, dtype, assert_packable): for z in range(-16, 0): z_typed = pd.Series(z, dtype=dtype) - if z != int(z_typed): - continue # not representable assert_packable(z_typed, bytes(bytearray([0x91, z + 0x100])), [z]) def test_positive_tiny_int(self, int_type, assert_packable): for z in range(0, 128): - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable assert_packable(z_typed, bytes(bytearray([z]))) def test_negative_int8(self, int_type, assert_packable): for z in range(-128, -16): - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable assert_packable(z_typed, bytes(bytearray([0xC8, z + 0x100]))) def test_positive_int16(self, int_type, assert_packable): for z in range(128, 32768): - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable expected = b"\xC9" + struct.pack(">h", z) assert_packable(z_typed, expected) def test_negative_int16(self, int_type, assert_packable): for z in range(-32768, -128): - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable expected = b"\xC9" + struct.pack(">h", z) assert_packable(z_typed, expected) @@ -220,8 +239,9 @@ def test_negative_int16(self, int_type, assert_packable): def test_positive_int32(self, int_type, assert_packable): for e in range(15, 31): z = 2 ** e - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable expected = b"\xCA" + struct.pack(">i", z) assert_packable(z_typed, expected) @@ -229,8 +249,9 @@ def test_positive_int32(self, int_type, assert_packable): def test_negative_int32(self, int_type, assert_packable): for e in range(15, 31): z = -(2 ** e + 1) - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable expected = b"\xCA" + struct.pack(">i", z) assert_packable(z_typed, expected) @@ -238,8 +259,9 @@ def test_negative_int32(self, int_type, assert_packable): def test_positive_int64(self, int_type, assert_packable): for e in range(31, 63): z = 2 ** e - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable expected = b"\xCB" + struct.pack(">q", z) assert_packable(z_typed, expected) @@ -258,8 +280,9 @@ def test_positive_int64_pandas_series(self, dtype, assert_packable): def test_negative_int64(self, int_type, assert_packable): for e in range(31, 63): z = -(2 ** e + 1) - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: continue # not representable expected = b"\xCB" + struct.pack(">q", z) assert_packable(z_typed, expected) @@ -277,16 +300,18 @@ def test_negative_int64_pandas_series(self, dtype, assert_packable): def test_integer_positive_overflow(self, int_type, pack, assert_packable): with pytest.raises(OverflowError): z = 2 ** 63 + 1 - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: pytest.skip("not representable") pack(z_typed) def test_integer_negative_overflow(self, int_type, pack, assert_packable): with pytest.raises(OverflowError): z = -(2 ** 63) - 1 - z_typed = int_type(z) - if z != int(z_typed): + try: + z_typed = int_type(z) + except OverflowError: pytest.skip("not representable") pack(z_typed) @@ -296,7 +321,11 @@ def test_float(self, float_type, assert_packable): *(float(2 ** e) + 0.5 for e in range(100)), *(-float(2 ** e) + 0.5 for e in range(100)), ): - z_typed = float_type(z) + print(z) + try: + z_typed = float_type(z) + except FloatingPointError: + continue # not representable expected = b"\xC1" + struct.pack(">d", float(z_typed)) assert_packable(z_typed, expected) @@ -304,13 +333,17 @@ def test_float(self, float_type, assert_packable): float, pd.Float32Dtype(), pd.Float64Dtype(), np.float16, np.float32, np.float64, np.longdouble, )) - def test_float_pandas_series(self, dtype, assert_packable): + def test_float_pandas_series(self, dtype, np_float_overflow_as_error, + assert_packable): for z in ( 0.0, -0.0, pi, 2 * pi, float("inf"), float("-inf"), float("nan"), *(float(2 ** e) + 0.5 for e in range(100)), *(-float(2 ** e) + 0.5 for e in range(100)), ): - z_typed = pd.Series(z, dtype=dtype) + try: + z_typed = pd.Series(z, dtype=dtype) + except FloatingPointError: + continue # not representable if z_typed[0] is pd.NA: expected_bytes = b"\x91\xC0" # encoded as NULL expected_value = [None] From 8fb3e4fdff6d6401d2605671563a195d62298420 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Tue, 3 Jan 2023 15:26:06 +0100 Subject: [PATCH 4/7] Alternative workaround for NEP50 --- requirements-dev.txt | 2 +- .../codec/packstream/v1/test_packstream.py | 87 +++++++++---------- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index eb05e75c2..b070002c1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,7 +18,7 @@ tomlkit~=0.11.6 # needed for running tests coverage[toml]>=5.5 mock>=4.0.3 -numpy>=1.24.0 # using numpy._set_promotion_state https://numpy.org/neps/nep-0050-scalar-promotion.html +numpy>=1.7.0 pandas>=1.0.0 pyarrow>=1.0.0 pytest>=6.2.5 diff --git a/tests/unit/common/codec/packstream/v1/test_packstream.py b/tests/unit/common/codec/packstream/v1/test_packstream.py index a7f4eed47..7e5bd4937 100644 --- a/tests/unit/common/codec/packstream/v1/test_packstream.py +++ b/tests/unit/common/codec/packstream/v1/test_packstream.py @@ -111,17 +111,13 @@ def _assert(in_value, packed_value, out_value=_default_out_value): return _assert -@pytest.fixture -def np_nep50_as_error(): - nep50_promotion_state = np._get_promotion_state() - np._set_promotion_state("weak") - yield - np._set_promotion_state(nep50_promotion_state) - - -@pytest.fixture -def np_float_overflow_as_error(): - old_err = np.seterr(over="raise") +@pytest.fixture(params=(True, False)) +def np_float_overflow_as_error(request): + should_raise = request.param + if should_raise: + old_err = np.seterr(over="raise") + else: + old_err = np.seterr(over="ignore") yield np.seterr(**old_err) @@ -132,14 +128,22 @@ def np_float_overflow_as_error(): np.int8, np.int16, np.int32, np.int64, np.longlong, np.uint8, np.uint16, np.uint32, np.uint64, np.ulonglong )) -def int_type(request, np_nep50_as_error): - yield request.param +def int_type(request): + if issubclass(request.param, np.number): + def _int_type(value): + # this avoids deprecation warning from NEP50 and forces + # c-style wrapping of the value + return np.array(value).astype(request.param).item() + + return _int_type + else: + return request.param @pytest.fixture(params=(float, np.float16, np.float32, np.float64, np.longdouble)) def float_type(request, np_float_overflow_as_error): - yield request.param + return request.param @pytest.fixture(params=(bool, np.bool_)) @@ -187,9 +191,8 @@ def test_boolean_pandas_series(self, dtype, assert_packable): def test_negative_tiny_int(self, int_type, assert_packable): for z in range(-16, 0): - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable assert_packable(z_typed, bytes(bytearray([z + 0x100]))) @@ -204,34 +207,30 @@ def test_negative_tiny_int_pandas_series(self, dtype, assert_packable): def test_positive_tiny_int(self, int_type, assert_packable): for z in range(0, 128): - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable assert_packable(z_typed, bytes(bytearray([z]))) def test_negative_int8(self, int_type, assert_packable): for z in range(-128, -16): - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable assert_packable(z_typed, bytes(bytearray([0xC8, z + 0x100]))) def test_positive_int16(self, int_type, assert_packable): for z in range(128, 32768): - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable expected = b"\xC9" + struct.pack(">h", z) assert_packable(z_typed, expected) def test_negative_int16(self, int_type, assert_packable): for z in range(-32768, -128): - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable expected = b"\xC9" + struct.pack(">h", z) assert_packable(z_typed, expected) @@ -239,9 +238,8 @@ def test_negative_int16(self, int_type, assert_packable): def test_positive_int32(self, int_type, assert_packable): for e in range(15, 31): z = 2 ** e - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable expected = b"\xCA" + struct.pack(">i", z) assert_packable(z_typed, expected) @@ -249,9 +247,8 @@ def test_positive_int32(self, int_type, assert_packable): def test_negative_int32(self, int_type, assert_packable): for e in range(15, 31): z = -(2 ** e + 1) - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable expected = b"\xCA" + struct.pack(">i", z) assert_packable(z_typed, expected) @@ -259,9 +256,8 @@ def test_negative_int32(self, int_type, assert_packable): def test_positive_int64(self, int_type, assert_packable): for e in range(31, 63): z = 2 ** e - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable expected = b"\xCB" + struct.pack(">q", z) assert_packable(z_typed, expected) @@ -280,9 +276,8 @@ def test_positive_int64_pandas_series(self, dtype, assert_packable): def test_negative_int64(self, int_type, assert_packable): for e in range(31, 63): z = -(2 ** e + 1) - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): continue # not representable expected = b"\xCB" + struct.pack(">q", z) assert_packable(z_typed, expected) @@ -300,18 +295,16 @@ def test_negative_int64_pandas_series(self, dtype, assert_packable): def test_integer_positive_overflow(self, int_type, pack, assert_packable): with pytest.raises(OverflowError): z = 2 ** 63 + 1 - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): pytest.skip("not representable") pack(z_typed) def test_integer_negative_overflow(self, int_type, pack, assert_packable): with pytest.raises(OverflowError): z = -(2 ** 63) - 1 - try: - z_typed = int_type(z) - except OverflowError: + z_typed = int_type(z) + if z != int(z_typed): pytest.skip("not representable") pack(z_typed) From 77f3ba7c4945abdc3d3ff0c5ee491881fe84daaa Mon Sep 17 00:00:00 2001 From: Florent Biville Date: Mon, 9 Jan 2023 08:40:16 +0100 Subject: [PATCH 5/7] Fix typo Signed-off-by: Rouven Bauer --- docs/source/api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index 5607eb2b2..478f54c81 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -1156,7 +1156,7 @@ Which in this case would yield:: 2. ``void`` and ``complexfloating`` typed numpy ``ndarray``\s are not supported. 3. ``Period``, ``Interval``, and ``pyarrow`` pandas types are not supported. - 4. A pandas ``DataFrame`` will be serialized Map from with the column names mapping to the column values (as lists). + 4. A pandas ``DataFrame`` will be serialized as Map with the column names mapping to the column values (as Lists). Just like with ``dict`` objects, the column names need to be :class:`str` objects. From 7ecfed66b86e1f1302a68d469fcbeffc300f3b32 Mon Sep 17 00:00:00 2001 From: Florent Biville Date: Mon, 9 Jan 2023 08:54:09 +0100 Subject: [PATCH 6/7] Refactor: centralize import attempts of optional deps Signed-off-by: Rouven Bauer --- .../_codec/hydration/v1/hydration_handler.py | 27 ++++++------------- src/neo4j/_codec/hydration/v1/temporal.py | 27 ++++++------------- .../_codec/hydration/v2/hydration_handler.py | 4 +-- src/neo4j/_codec/hydration/v2/temporal.py | 3 +-- src/neo4j/_codec/packstream/v1/__init__.py | 17 +++++------- src/neo4j/_optional_deps/__init__.py | 22 +++++++++++++++ 6 files changed, 47 insertions(+), 53 deletions(-) create mode 100644 src/neo4j/_optional_deps/__init__.py diff --git a/src/neo4j/_codec/hydration/v1/hydration_handler.py b/src/neo4j/_codec/hydration/v1/hydration_handler.py index 994b26d81..b4bb09658 100644 --- a/src/neo4j/_codec/hydration/v1/hydration_handler.py +++ b/src/neo4j/_codec/hydration/v1/hydration_handler.py @@ -23,21 +23,10 @@ timedelta, ) - -try: - import numpy as np - - NUMPY_AVAILABLE = True -except ImportError: - NUMPY_AVAILABLE = False - -try: - import pandas as pd - - PANDAS_AVAILABLE = True -except ImportError: - PANDAS_AVAILABLE = False - +from ...._optional_deps import ( + np, + pd, +) from ....graph import ( Graph, Node, @@ -187,12 +176,12 @@ def __init__(self): Duration: temporal.dehydrate_duration, timedelta: temporal.dehydrate_timedelta, }) - if NUMPY_AVAILABLE: + if np is not None: self.dehydration_hooks.update(exact_types={ np.datetime64: temporal.dehydrate_np_datetime, np.timedelta64: temporal.dehydrate_np_timedelta, }) - if PANDAS_AVAILABLE: + if pd is not None: self.dehydration_hooks.update(exact_types={ pd.Timestamp: temporal.dehydrate_pandas_datetime, pd.Timedelta: temporal.dehydrate_pandas_timedelta, @@ -216,11 +205,11 @@ def patch_utc(self): DateTime: temporal_v2.dehydrate_datetime, datetime: temporal_v2.dehydrate_datetime, }) - if NUMPY_AVAILABLE: + if np is not None: self.dehydration_hooks.update(exact_types={ np.datetime64: temporal_v2.dehydrate_np_datetime, }) - if PANDAS_AVAILABLE: + if pd is not None: self.dehydration_hooks.update(exact_types={ pd.Timestamp: temporal_v2.dehydrate_pandas_datetime, }) diff --git a/src/neo4j/_codec/hydration/v1/temporal.py b/src/neo4j/_codec/hydration/v1/temporal.py index c9f4bf3cf..d47967551 100644 --- a/src/neo4j/_codec/hydration/v1/temporal.py +++ b/src/neo4j/_codec/hydration/v1/temporal.py @@ -22,21 +22,10 @@ timedelta, ) - -try: - import numpy as np - - NUMPY_AVAILABLE = True -except ImportError: - NUMPY_AVAILABLE = False - -try: - import pandas as pd - - PANDAS_AVAILABLE = True -except ImportError: - PANDAS_AVAILABLE = False - +from ...._optional_deps import ( + np, + pd, +) from ....time import ( Date, DateTime, @@ -189,7 +178,7 @@ def seconds_and_nanoseconds(dt): int(tz.utcoffset(value).total_seconds())) -if NUMPY_AVAILABLE: +if np is not None: def dehydrate_np_datetime(value): """ Dehydrator for `numpy.datetime64` values. @@ -211,7 +200,7 @@ def dehydrate_np_datetime(value): return Structure(b"d", seconds, nanoseconds) -if PANDAS_AVAILABLE: +if pd is not None: def dehydrate_pandas_datetime(value): """ Dehydrator for `pandas.Timestamp` values. @@ -269,7 +258,7 @@ def dehydrate_timedelta(value): return Structure(b"E", months, days, seconds, nanoseconds) -if NUMPY_AVAILABLE: +if np is not None: _NUMPY_DURATION_UNITS = { "Y": "years", "M": "months", @@ -303,7 +292,7 @@ def dehydrate_np_timedelta(value): )) -if PANDAS_AVAILABLE: +if pd is not None: def dehydrate_pandas_timedelta(value): """ Dehydrator for `pandas.Timedelta` values. diff --git a/src/neo4j/_codec/hydration/v2/hydration_handler.py b/src/neo4j/_codec/hydration/v2/hydration_handler.py index 94d4e82db..83348b3b7 100644 --- a/src/neo4j/_codec/hydration/v2/hydration_handler.py +++ b/src/neo4j/_codec/hydration/v2/hydration_handler.py @@ -50,12 +50,12 @@ def __init__(self): Duration: temporal.dehydrate_duration, timedelta: temporal.dehydrate_timedelta, }) - if NUMPY_AVAILABLE: + if np is not None: self.dehydration_hooks.update(exact_types={ np.datetime64: temporal.dehydrate_np_datetime, np.timedelta64: temporal.dehydrate_np_timedelta, }) - if PANDAS_AVAILABLE: + if pd is not None: self.dehydration_hooks.update(exact_types={ pd.Timestamp: temporal.dehydrate_pandas_datetime, pd.Timedelta: temporal.dehydrate_pandas_timedelta, diff --git a/src/neo4j/_codec/hydration/v2/temporal.py b/src/neo4j/_codec/hydration/v2/temporal.py index 3ed1e4596..d15b37536 100644 --- a/src/neo4j/_codec/hydration/v2/temporal.py +++ b/src/neo4j/_codec/hydration/v2/temporal.py @@ -16,7 +16,6 @@ # limitations under the License. -from ....time import UnixEpoch from ..v1.temporal import * @@ -93,7 +92,7 @@ def seconds_and_nanoseconds(dt): return Structure(b"I", seconds, nanoseconds, offset_seconds) -if PANDAS_AVAILABLE: +if pd is not None: def dehydrate_pandas_datetime(value): """ Dehydrator for `pandas.Timestamp` values. diff --git a/src/neo4j/_codec/packstream/v1/__init__.py b/src/neo4j/_codec/packstream/v1/__init__.py index 1e5586e60..89a160ad6 100644 --- a/src/neo4j/_codec/packstream/v1/__init__.py +++ b/src/neo4j/_codec/packstream/v1/__init__.py @@ -24,6 +24,10 @@ unpack as struct_unpack, ) +from ...._optional_deps import ( + np, + pd, +) from ...hydration import DehydrationHooks from .._common import Structure @@ -40,29 +44,20 @@ BYTES_TYPES: t.Tuple[t.Type, ...] = (bytes, bytearray) -try: - import numpy as np - +if np is not None: TRUE_VALUES += (np.bool_(True),) FALSE_VALUES += (np.bool_(False),) INT_TYPES = (*INT_TYPES, np.integer) FLOAT_TYPES = (*FLOAT_TYPES, np.floating) SEQUENCE_TYPES = (*SEQUENCE_TYPES, np.ndarray) - NUMPY_AVAILABLE = True -except ImportError: - NUMPY_AVAILABLE = False -try: - import pandas as pd +if pd is not None: import pandas.core.arrays NONE_VALUES += (pd.NA,) SEQUENCE_TYPES = (*SEQUENCE_TYPES, pd.Series, pd.Categorical, pd.core.arrays.ExtensionArray) MAPPING_TYPES = (*MAPPING_TYPES, pd.DataFrame) - PANDAS_AVAILABLE = True -except ImportError: - PANDAS_AVAILABLE = False PACKED_UINT_8 = [struct_pack(">B", value) for value in range(0x100)] diff --git a/src/neo4j/_optional_deps/__init__.py b/src/neo4j/_optional_deps/__init__.py new file mode 100644 index 000000000..17aa1b61d --- /dev/null +++ b/src/neo4j/_optional_deps/__init__.py @@ -0,0 +1,22 @@ +import typing as t + + +np: t.Any = None + +try: + import numpy as np # type: ignore[no-redef] +except ImportError: + pass + +pd: t.Any = None + +try: + import pandas as pd # type: ignore[no-redef] +except ImportError: + pass + + +__all__ = [ + "np", + "pd", +] From 0dce3c24db20c440930bd9fdf4da9f4137c0be10 Mon Sep 17 00:00:00 2001 From: Grant Lodge <6323995+thelonelyvulpes@users.noreply.github.com> Date: Mon, 9 Jan 2023 09:37:02 +0100 Subject: [PATCH 7/7] Refactor: unify tuple concatenation Signed-off-by: Rouven Bauer --- src/neo4j/_codec/packstream/v1/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/neo4j/_codec/packstream/v1/__init__.py b/src/neo4j/_codec/packstream/v1/__init__.py index 89a160ad6..360cd25b4 100644 --- a/src/neo4j/_codec/packstream/v1/__init__.py +++ b/src/neo4j/_codec/packstream/v1/__init__.py @@ -45,16 +45,14 @@ if np is not None: - TRUE_VALUES += (np.bool_(True),) - FALSE_VALUES += (np.bool_(False),) + TRUE_VALUES = (*TRUE_VALUES, np.bool_(True)) + FALSE_VALUES = (*FALSE_VALUES, np.bool_(False)) INT_TYPES = (*INT_TYPES, np.integer) FLOAT_TYPES = (*FLOAT_TYPES, np.floating) SEQUENCE_TYPES = (*SEQUENCE_TYPES, np.ndarray) if pd is not None: - import pandas.core.arrays - - NONE_VALUES += (pd.NA,) + NONE_VALUES = (*NONE_VALUES, pd.NA) SEQUENCE_TYPES = (*SEQUENCE_TYPES, pd.Series, pd.Categorical, pd.core.arrays.ExtensionArray) MAPPING_TYPES = (*MAPPING_TYPES, pd.DataFrame)