From: Daniele Varrazzo Date: Fri, 14 May 2021 15:50:06 +0000 (+0200) Subject: Return timestamptz in the connection timezone X-Git-Tag: 3.0.dev0~42^2~15 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=d2b5285857fa6ade4af341feb3927061410d4757;p=thirdparty%2Fpsycopg.git Return timestamptz in the connection timezone First cut, more tests to add. All current tests pass, except the explicit checks for UTC tzinfo returned. See https://github.com/psycopg/psycopg3/discussions/56 --- diff --git a/psycopg3/psycopg3/connection.py b/psycopg3/psycopg3/connection.py index d5472437a..7ba7595da 100644 --- a/psycopg3/psycopg3/connection.py +++ b/psycopg3/psycopg3/connection.py @@ -31,7 +31,7 @@ from .conninfo import make_conninfo, ConnectionInfo from .generators import notifies from ._preparing import PrepareManager from .transaction import Transaction, AsyncTransaction -from .utils.compat import asynccontextmanager +from .utils.compat import asynccontextmanager, ZoneInfo from .server_cursor import ServerCursor, AsyncServerCursor logger = logging.getLogger("psycopg3") @@ -59,6 +59,8 @@ else: connect = generators.connect execute = generators.execute +_UTC = ZoneInfo("UTC") + class Notify(NamedTuple): """An asynchronous notification received from the database.""" @@ -219,6 +221,22 @@ class BaseConnection(AdaptContext, Generic[Row]): if result.status != ExecStatus.TUPLES_OK: raise e.error_from_result(result, encoding=self.client_encoding) + @property + def timezone(self) -> ZoneInfo: + """The Python timezone info of the connection's timezone.""" + tzname = self.pgconn.parameter_status(b"TimeZone") + if tzname: + try: + return ZoneInfo(tzname.decode("utf8")) + except KeyError: + logger.warning( + "unknown PostgreSQL timezone: %r will use UTC", + tzname.decode("utf8"), + ) + return _UTC + else: + return _UTC + @property def info(self) -> ConnectionInfo: """A `ConnectionInfo` attribute to inspect connection properties.""" diff --git a/psycopg3/psycopg3/types/date.py b/psycopg3/psycopg3/types/date.py index e4811494e..892a14227 100644 --- a/psycopg3/psycopg3/types/date.py +++ b/psycopg3/psycopg3/types/date.py @@ -15,6 +15,7 @@ from ..oids import postgres_types as builtins from ..adapt import Buffer, Dumper, Loader, Format as Pg3Format from ..proto import AdaptContext from ..errors import InterfaceError, DataError +from ..utils.compat import ZoneInfo _PackInt = Callable[[int], bytes] _UnpackInt = Callable[[bytes], Tuple[int]] @@ -40,6 +41,8 @@ _pg_datetime_epoch = datetime(2000, 1, 1) _pg_datetimetz_epoch = datetime(2000, 1, 1, tzinfo=timezone.utc) _py_date_min_days = date.min.toordinal() +_UTC = ZoneInfo("UTC") + class DateDumper(Dumper): @@ -517,6 +520,10 @@ class TimestampTzLoader(TimestampLoader): format = Format.TEXT + def __init__(self, oid: int, context: Optional[AdaptContext] = None): + super().__init__(oid, context) + self._timezone = self.connection.timezone if self.connection else _UTC + def _format_from_context(self) -> str: ds = self._get_datestyle() if ds.startswith(b"I"): # ISO @@ -557,7 +564,7 @@ class TimestampTzLoader(TimestampLoader): if data[-3] in (43, 45): data += b"00" - return super().load(data).astimezone(timezone.utc) + return super().load(data).astimezone(self._timezone) def _load_py36(self, data: Buffer) -> datetime: if isinstance(data, memoryview): @@ -579,7 +586,7 @@ class TimestampTzLoader(TimestampLoader): tzoff = -tzoff rv = super().load(data[: m.start()]) - return (rv - tzoff).replace(tzinfo=timezone.utc) + return (rv - tzoff).replace(tzinfo=self._timezone) def _load_notimpl(self, data: Buffer) -> datetime: if isinstance(data, memoryview): @@ -598,10 +605,15 @@ class TimestampTzBinaryLoader(Loader): format = Format.BINARY + def __init__(self, oid: int, context: Optional[AdaptContext] = None): + super().__init__(oid, context) + self._timezone = self.connection.timezone if self.connection else _UTC + def load(self, data: Buffer) -> datetime: micros = _unpack_int8(data)[0] try: - return _pg_datetimetz_epoch + timedelta(microseconds=micros) + ts = _pg_datetimetz_epoch + timedelta(microseconds=micros) + return ts.astimezone(self._timezone) except OverflowError: if micros <= 0: raise DataError("timestamp too small (before year 1)") diff --git a/psycopg3/psycopg3/utils/compat.py b/psycopg3/psycopg3/utils/compat.py index 7f97bc05a..5c1ed180d 100644 --- a/psycopg3/psycopg3/utils/compat.py +++ b/psycopg3/psycopg3/utils/compat.py @@ -49,9 +49,14 @@ else: Task = asyncio.Future +if sys.version_info >= (3, 9): + from zoneinfo import ZoneInfo +else: + from backports.zoneinfo import ZoneInfo __all__ = [ "Protocol", + "ZoneInfo", "asynccontextmanager", "create_task", "get_running_loop", diff --git a/psycopg3/setup.cfg b/psycopg3/setup.cfg index 85164391f..1bc23b383 100644 --- a/psycopg3/setup.cfg +++ b/psycopg3/setup.cfg @@ -30,6 +30,7 @@ python_requires = >= 3.6 packages = find: zip_safe = False install_requires = + backports.zoneinfo; python_version < "3.9" typing_extensions; python_version < "3.8" [options.package_data] diff --git a/tests/types/test_date.py b/tests/types/test_date.py index 5041fdded..607eb45d9 100644 --- a/tests/types/test_date.py +++ b/tests/types/test_date.py @@ -258,7 +258,6 @@ def test_load_datetimetz(conn, val, expr, timezone, datestyle_out): cur.execute(f"set timezone to '{timezone}'") got = cur.execute(f"select '{expr}'::timestamptz").fetchone()[0] assert got == as_dt(val) - assert got.tzinfo == dt.timezone.utc @pytest.mark.parametrize("val, expr, timezone", load_datetimetz_samples) @@ -267,7 +266,6 @@ def test_load_datetimetz_binary(conn, val, expr, timezone): cur.execute(f"set timezone to '{timezone}'") got = cur.execute(f"select '{expr}'::timestamptz").fetchone()[0] assert got == as_dt(val) - assert got.tzinfo == dt.timezone.utc @pytest.mark.xfail # parse timezone names