Skip to content

Commit

Permalink
switch to whenever over arrow
Browse files Browse the repository at this point in the history
  • Loading branch information
Fuyukai committed Apr 12, 2024
1 parent 6be678a commit 34126bd
Show file tree
Hide file tree
Showing 11 changed files with 377 additions and 381 deletions.
6 changes: 6 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Changelog
=========

0.11.0
------

- Switch to the ``whenever`` package for date/time types, instead of Arrow. See
https://dev.arie.bovenberg.net/blog/python-datetime-pitfalls/ for more information as to why.

0.10.0 (2023-12-22)
-------------------

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,5 @@
"python": ("https://docs.python.org/3", None),
"anyio": ("https://anyio.readthedocs.io/en/stable/", None),
"trio": ("https://trio.readthedocs.io/en/stable/", None),
"arrow": ("https://arrow.readthedocs.io/en/latest/", None),
"whenever": ("https://whenever.readthedocs.io/en/latest/", None),
}
16 changes: 7 additions & 9 deletions docs/conversion.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,17 @@ PostgreSQL core types.
Date/Time types
~~~~~~~~~~~~~~~

- ``TIMESTAMP WITH TIMEZONE`` and ``TIMESTAMP WITHOUT TIMEZONE`` are mapped to
:class:`~arrow.arrow.Arrow` instances. The server timezone and UTC are used for timezones
respectively, so it's all handled automatically.

.. note::

I use Arrow over the vanilla ``datetime`` objects because I don't like ``datetime``. Write your
own converter if you disagree with me.

- ``TIMESTAMP WITH TIMEZONE`` is mapped to :class:`~whenever.OffsetDatetime`.
- ``TIMESTAMP WITHOUT TIMEZONE`` is mapped to :class:`~whenever.NaiveDatetime`.
- ``DATE`` is mapped to :class:`datetime.date`.
- ``TIME WITHOUT TIMEZONE`` is mapped to :class:`datetime.time`. ``TIME WITH TIMEZONE``
`isn't supported <https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_timetz>`__.

.. note::

I use Whenever over the vanilla ``datetime`` objects because I don't like ``datetime``. Write
your own converter if you disagree with me.

Enumeration types
~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 2 additions & 2 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ Whilst ``pg-purepy`` has no C dependencies, it does have some external Python de
- anyio_ is used for connecting to the database asynchronously.
- scramp_ is used for SASL authentication.
- attrs_ is used to create the message object classes.
- arrow_ is used for better datetime types.
- whenever_ is used for better datetime types.

.. _anyio: https://anyio.readthedocs.io/en/stable/
.. _scramp: https://github.com/tlocke/scramp
.. _attrs: https://www.attrs.org/en/stable/
.. _arrow: https://arrow.readthedocs.io/en/latest/
.. _whenever: https://whenever.readthedocs.io/en/latest/
581 changes: 284 additions & 297 deletions poetry.lock

Large diffs are not rendered by default.

50 changes: 27 additions & 23 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"

[tool.poetry]
name = "pg-purepy"
version = "0.10.0"
description = "A pure-Python anyio-based PostgreSQL adapter."
authors = ["Lura Skye <[email protected]>"]
authors = ["Lura Skye <[email protected]>"]
license = "LGPL-3.0-or-later"
classifiers = [
"Development Status :: 4 - Beta",
Expand All @@ -15,28 +19,28 @@ readme = "README.rst"
[tool.poetry.dependencies]
python = ">=3.11"
scramp = ">=1.4.4"
attrs = ">=23.1.0"
arrow = ">=1.3.0"
python-dateutil = ">=2.8.2"
anyio = ">=4.0.0"
structlog = ">=23.2.0"
attrs = ">=23.2.0"
python-dateutil = ">=2.9.0.post0"
anyio = ">=4.3.0"
structlog = ">=24.1.0"
whenever = ">=0.5.1"

[tool.poetry.group.docs.dependencies]
sphinx = ">=7.2.6"
sphinx-rtd-theme = ">=1.3.0"
sphinx-rtd-theme = ">=2.0.0"
sphinx-inline-tabs = ">=2023.4.21"
sphinx-autodoc-typehints = ">=1.25.2"
sphinx-autodoc-typehints = ">=2.0.1"
sphinxcontrib-jquery = ">=4.1"

[tool.poetry.group.dev.dependencies]
pytest = ">=7.4.3"
trio = ">=0.23.1"
isort = ">=5.12.0"
pytest-cov = ">=4.1.0"
mypy = ">=1.7.1"
ruff = ">=0.1.8"
ujson = ">=5.8.0"
pyright = ">=1.1.342"
pytest = ">=8.1.1"
trio = ">=0.25.0"
isort = ">=5.13.2"
pytest-cov = ">=5.0.0"
mypy = ">=1.9.0"
ruff = ">=0.3.7"
ujson = ">=5.9.0"
pyright = ">=1.1.358"

[tool.poetry.extras]
docs = ["Sphinx", "sphinxcontrib-trio", "sphinx-autodoc-typehints", "sphinx-rtd-theme",
Expand All @@ -58,13 +62,14 @@ exclude_lines = [
]

[tool.ruff]
target-version = "py311"
target-version = "py312"
respect-gitignore = true
# fix = true
src = ["src/pg_purepy"]
line-length = 100
show-source = true
output-format = "full"

[tool.ruff.lint]
select = [
"RUF",
"F",
Expand All @@ -82,12 +87,12 @@ select = [
"PERF",
]
ignore = [
"W291", # SHUT THE FUCK UP WHEN I'M TYPING COMMENTS
"W291",
"W293",
"E266"
]

[tool.ruff.isort]
[tool.ruff.lint.isort]
combine-as-imports = true

[tool.mypy]
Expand All @@ -106,6 +111,5 @@ disallow_subclassing_any = true
disallow_untyped_calls = true
check_untyped_defs = true

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry-dynamic-versioning]
enable = true
18 changes: 11 additions & 7 deletions src/pg_purepy/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import warnings
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from contextlib import aclosing, asynccontextmanager
from datetime import tzinfo
from os import PathLike, fspath
from ssl import SSLContext
from typing import (
Expand Down Expand Up @@ -123,19 +122,22 @@ def connection_parameters(self) -> Mapping[str, str]:
"""
Returns a read-only view of the current connection;
"""

return types.MappingProxyType(self._protocol.connection_params)

@property
def server_timezone(self) -> tzinfo:
def server_timezone(self) -> str:
"""
Returns the timezone of the server.
"""

return self._protocol.timezone

def add_converter(self, converter: Converter) -> None:
"""
Registers a :class:`.Converter` with this connection.
"""

self._protocol.add_converter(converter)

def __repr__(self) -> str:
Expand Down Expand Up @@ -284,11 +286,13 @@ async def lowlevel_query(
if not self._protocol.ready:
await self.wait_until_ready()

simple_query = all((
not (params or kwargs),
not isinstance(query, PreparedStatementInfo),
max_rows is None,
))
simple_query = all(
(
not (params or kwargs),
not isinstance(query, PreparedStatementInfo),
max_rows is None,
)
)

logger.debug("Executing query", query=query)
if simple_query:
Expand Down
8 changes: 3 additions & 5 deletions src/pg_purepy/conversion/abc.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import abc
from datetime import tzinfo
from typing import Any

import attr
Expand Down Expand Up @@ -40,12 +39,11 @@ def to_postgres(self, context: ConversionContext, data: Any) -> str:
@attr.s(slots=True, frozen=False)
class ConversionContext:
"""
A conversion context contains information that might be needed to convert from the PostgreSQL
string representation to the real representation.
Information that may be needed during conversion from PostgreSQL types.
"""

#: The encoding of the client.
client_encoding: str = attr.ib()

#: The timezone of the server.
timezone: tzinfo = attr.ib(default=UTC)
#: The raw timezone of the server.
timezone: str = attr.ib(default=UTC)
29 changes: 17 additions & 12 deletions src/pg_purepy/conversion/dt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import datetime
from typing import TYPE_CHECKING, Literal, assert_never

import arrow
import dateutil
import dateutil.parser
import whenever

from pg_purepy.conversion.abc import Converter
from pg_purepy.conversion.arrays import ArrayConverter
Expand All @@ -19,19 +21,22 @@ class TimestampTzConverter(Converter):

oid = 1184

def from_postgres(self, context: ConversionContext, data: str) -> arrow.Arrow | str:
def from_postgres(self, context: ConversionContext, data: str) -> whenever.OffsetDateTime | str:
if data == "infinity" or data == "-infinity":
return data

# TIMESTAMPTZ are stored in UTC, and are converted to the server's timezone on retrieval.
# So we provide the returned date in the server's timezone.
return arrow.get(data, tzinfo=context.timezone)
parsed = dateutil.parser.isoparse(data)

# can't directly pass the datetime as it'll complain about UTC.
return whenever.OffsetDateTime.from_rfc3339(parsed.isoformat())

def to_postgres(
self, context: ConversionContext, data: Literal["infinity", "-infinity"] | arrow.Arrow
self,
context: ConversionContext,
data: Literal["infinity", "-infinity"] | whenever.OffsetDateTime,
) -> str:
# There's some really jank type stuff going on here, mypy can't narrow the type properly
# here?

match data:
case "infinity":
Expand All @@ -40,8 +45,8 @@ def to_postgres(
case "-infinity":
return "-infinity"

case arrow.Arrow():
return data.isoformat()
case whenever.OffsetDateTime():
return data.rfc3339()

case _:
assert_never(data)
Expand All @@ -58,13 +63,13 @@ class TimestampNoTzConverter(Converter):

oid = 1114

def from_postgres(self, context: ConversionContext, data: str) -> arrow.Arrow | str:
def from_postgres(self, context: ConversionContext, data: str) -> whenever.NaiveDateTime | str:
if data == "infinity" or data == "-infinity":
return data

return arrow.get(data) # No timezone!
return whenever.NaiveDateTime.from_common_iso8601(data)

def to_postgres(self, context: ConversionContext, data: arrow.Arrow) -> str:
def to_postgres(self, context: ConversionContext, data: whenever.NaiveDateTime) -> str:
# we can once again just do isoformat, because postgres will parse it, go "nice offset.
# fuck you" and completely discard it.
#
Expand All @@ -75,7 +80,7 @@ def to_postgres(self, context: ConversionContext, data: arrow.Arrow) -> str:
# | 2021-07-13 22:16:36 |
# +---------------------+

return data.isoformat()
return data.common_iso8601()


STATIC_TIMESTAMPNOTZ_CONVERTER = TimestampNoTzConverter()
Expand Down
28 changes: 11 additions & 17 deletions src/pg_purepy/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@
import functools
import struct
from collections.abc import Callable, Collection, Mapping
from datetime import tzinfo
from hashlib import md5
from itertools import count as it_count
from typing import Any

import dateutil.tz
import structlog
from scramp import ScramClient
from structlog.stdlib import BoundLogger
Expand Down Expand Up @@ -121,17 +119,17 @@ class NeedData:
_NO_HANDLE = object()


def unrecoverable_error(
fn: Callable[[SansIOClient, BackendMessageCode, Buffer], PostgresMessage],
) -> Callable[[SansIOClient, BackendMessageCode, Buffer], PostgresMessage]:
def unrecoverable_error[_Self: "SansIOClient"](
fn: Callable[[_Self, BackendMessageCode, Buffer], PostgresMessage],
) -> Callable[[_Self, BackendMessageCode, Buffer], PostgresMessage]:
"""
Decorator that will automatically set the state to an unrecoverable error if an error response
is found.
"""

@functools.wraps(fn)
def wrapper(
self: SansIOClient, code: BackendMessageCode, body: Buffer
self: _Self, code: BackendMessageCode, body: Buffer
) -> ErrorOrNoticeResponse | PostgresMessage:
if code == BackendMessageCode.ERROR_RESPONSE:
error = self._decode_error_response(body, recoverable=False, notice=False)
Expand Down Expand Up @@ -259,7 +257,6 @@ class ProtocolState(enum.Enum):
TERMINATED = 9999


# noinspection PyMethodMayBeStatic,PyUnresolvedReferences
class SansIOClient:
"""
Sans-I/O state machine for the PostgreSQL C<->S protocol. This operates as an in-memory buffer
Expand Down Expand Up @@ -376,13 +373,15 @@ def encoding(self) -> str:
"""
The client encoding.
"""

return self._conversion_context.client_encoding

@property
def timezone(self) -> tzinfo:
def timezone(self) -> str:
"""
The server timezone.
The raw server timezone.
"""

return self._conversion_context.timezone

@property
Expand Down Expand Up @@ -420,15 +419,10 @@ def _handle_parameter_status(self, body: Buffer) -> ParameterStatus:
if name == "client_encoding":
self._conversion_context.client_encoding = value
elif name == "TimeZone":
gotten = dateutil.tz.gettz(value)
if gotten is None:
if value is None:
raise ValueError(f"PG returned invalid timezone {value}!")

# coerce 'UTC' zoneinfo into tzutc
if gotten == dateutil.tz.gettz("UTC"):
gotten = dateutil.tz.UTC

self._conversion_context.timezone = gotten
self._conversion_context.timezone = value
else:
self.connection_params[name] = value

Expand Down Expand Up @@ -1110,7 +1104,7 @@ def next_event(self) -> PostgresMessage | NeedData:
# rough flow:
# 1) copy off the main buffer into the packet buffer
# 2) if we have enough data, unflip the flag, reset buffers
# 3) if we don't hjave enough data, just return NEED_DATA, we'll check again next loop
# 3) if we don't have enough data, just return NEED_DATA, we'll check again next loop

remaining = self._current_packet_remaining
assert remaining > 0, "partial packet needs actual data to read"
Expand Down
Loading

0 comments on commit 34126bd

Please sign in to comment.