Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Datetime Autoconversion for Timestamptz Columns #959

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8cfa0c7
Added column tz autoconversion to Table __init__ method
aabmets Mar 17, 2024
95d8e4d
Added tz ability to Timestamptz
aabmets Mar 18, 2024
7dd76d8
Added tz ability to TimestamptzNow
aabmets Mar 18, 2024
06e0625
Added timezone awareness to TimestamptzOffset and TimestamptzCustom
aabmets Mar 19, 2024
c589d44
Added zoneinfo fallback dependency for missing zone data
aabmets Mar 19, 2024
fc708fc
Added backports.zoneinfo
aabmets Mar 19, 2024
705629a
Un-privatized tz attribute from default timestamptz classes
aabmets Mar 19, 2024
a25e814
Added python version constraint to backports.zoneinfo
aabmets Mar 19, 2024
86004b0
Merge branch 'piccolo-orm:master' into timestamptz-autoconversion
aabmets Mar 20, 2024
6611a97
Updated Timestamptz docstring and fixed a bug in TimestamptzCustom.fr…
aabmets Mar 20, 2024
aa9f077
Fixed bug in TimestamptzCustom.from_datetime
aabmets Mar 20, 2024
6fcb93f
Fixed linter and test issues (hopefully)
aabmets Mar 26, 2024
59a531e
Added backport.zoneinfo import with try-except clause, fixed imports …
aabmets Mar 28, 2024
7a3c584
Fixed linting errors across codebase and fixed timestamptz test error…
aabmets Mar 28, 2024
41a6d0c
Fixed zoneinfo module import for autogenerated migrations files
aabmets Mar 28, 2024
ad0d1d1
I swear to god if this commit doesn't fix the linting and test errors
aabmets Mar 28, 2024
c816d8e
Added more ZoneInfo import ignore rules for MyPy
aabmets Mar 28, 2024
cbf6d41
Added pragma no cover to ZoneInfo import except clauses
aabmets Mar 28, 2024
db53939
Removed ZoneInfo import from table.py
aabmets Mar 29, 2024
c251fb1
Merge branch 'master' into pr/959
dantownsend Apr 5, 2024
64cb95e
add missing `continue`
dantownsend Apr 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 36 additions & 1 deletion piccolo/apps/migrations/auto/serialisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,22 @@
from dataclasses import dataclass, field
from enum import Enum

from piccolo.columns import Column
from piccolo.columns import Column, Timestamptz
from piccolo.columns.defaults.base import Default
from piccolo.columns.defaults.timestamptz import (
TimestamptzCustom,
TimestamptzNow,
TimestamptzOffset,
)
from piccolo.columns.reference import LazyTableReference
from piccolo.table import Table
from piccolo.utils.repr import repr_class_instance

try:
from zoneinfo import ZoneInfo # type: ignore
except ImportError: # pragma: no cover
from backports.zoneinfo import ZoneInfo # type: ignore # noqa: F401

from .serialisation_legacy import deserialise_legacy_params

###############################################################################
Expand Down Expand Up @@ -546,6 +556,30 @@ def serialise_params(params: t.Dict[str, t.Any]) -> SerialisedParams:
expect_conflict_with_global_name=UniqueGlobalNames.DEFAULT,
)
)
# ZoneInfo for Timestamptz* instances
in_group = (
Timestamptz,
TimestamptzNow,
TimestamptzCustom,
TimestamptzOffset,
)
if isinstance(value, in_group):
extra_imports.append(
Import(
module=ZoneInfo.__module__,
target=None,
)
)
continue

# ZoneInfo instances
if isinstance(value, ZoneInfo):
extra_imports.append(
Import(
module=value.__class__.__module__,
target=None,
)
)
continue

# Dates and times
Expand Down Expand Up @@ -633,6 +667,7 @@ def serialise_params(params: t.Dict[str, t.Any]) -> SerialisedParams:
extra_imports.append(
Import(module=module_name, target=type_.__name__)
)
continue

# Functions
if inspect.isfunction(value):
Expand Down
41 changes: 27 additions & 14 deletions piccolo/columns/column_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class Band(Table):
from piccolo.utils.encoding import dump_json
from piccolo.utils.warnings import colored_warning

try:
from zoneinfo import ZoneInfo # type: ignore
except ImportError: # pragma: no cover
from backports.zoneinfo import ZoneInfo # type: ignore # noqa: F401

if t.TYPE_CHECKING: # pragma: no cover
from piccolo.columns.base import ColumnMeta
from piccolo.table import Table
Expand Down Expand Up @@ -955,36 +960,40 @@ def __set__(self, obj, value: t.Union[datetime, None]):
class Timestamptz(Column):
"""
Used for storing timezone aware datetimes. Uses the ``datetime`` type for
values. The values are converted to UTC in the database, and are also
returned as UTC.
values. The values are converted to UTC when saved into the database and
are converted back into the timezone of the column on select queries.

**Example**

.. code-block:: python

import datetime
from zoneinfo import ZoneInfo

class Concert(Table):
starts = Timestamptz()
class TallinnConcerts(Table):
event_start = Timestamptz(tz=ZoneInfo("Europe/Tallinn"))

# Create
>>> await Concert(
... starts=datetime.datetime(
... year=2050, month=1, day=1, tzinfo=datetime.timezone.tz
>>> await TallinnConcerts(
... event_start=datetime.datetime(
... year=2050, month=1, day=1, hour=20
... )
... ).save()

# Query
>>> await Concert.select(Concert.starts)
>>> await TallinnConcerts.select(TallinnConcerts.event_start)
{
'starts': datetime.datetime(
2050, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
'event_start': datetime.datetime(
2050, 1, 1, 20, 0, tzinfo=zoneinfo.ZoneInfo(
key='Europe/Tallinn'
)
)
}

"""

value_type = datetime
tz_type = ZoneInfo

# Currently just used by ModelBuilder, to know that we want a timezone
# aware datetime.
Expand All @@ -993,20 +1002,24 @@ class Concert(Table):
timedelta_delegate = TimedeltaDelegate()

def __init__(
self, default: TimestamptzArg = TimestamptzNow(), **kwargs
self,
tz: ZoneInfo = ZoneInfo("UTC"),
default: TimestamptzArg = TimestamptzNow(),
**kwargs,
) -> None:
self._validate_default(
default, TimestamptzArg.__args__ # type: ignore
)

if isinstance(default, datetime):
default = TimestamptzCustom.from_datetime(default)
default = TimestamptzCustom.from_datetime(default, tz)

if default == datetime.now:
default = TimestamptzNow()
default = TimestamptzNow(tz)

self.tz = tz
self.default = default
kwargs.update({"default": default})
kwargs.update({"tz": tz, "default": default})
super().__init__(**kwargs)

###########################################################################
Expand Down
61 changes: 51 additions & 10 deletions piccolo/columns/defaults/timestamptz.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
from __future__ import annotations

import datetime
import datetime as pydatetime
import typing as t
from enum import Enum

try:
from zoneinfo import ZoneInfo # type: ignore
except ImportError: # pragma: no cover
from backports.zoneinfo import ZoneInfo # type: ignore # noqa: F401

from .timestamp import TimestampCustom, TimestampNow, TimestampOffset


class TimestamptzOffset(TimestampOffset):
def __init__(
self,
days: int = 0,
hours: int = 0,
minutes: int = 0,
seconds: int = 0,
tz: ZoneInfo = ZoneInfo("UTC"),
):
self.tz = tz
super().__init__(
days=days, hours=hours, minutes=minutes, seconds=seconds
)

@property
def cockroach(self):
interval_string = self.get_postgres_interval_string(
Expand All @@ -16,9 +34,7 @@ def cockroach(self):
return f"CURRENT_TIMESTAMP + INTERVAL '{interval_string}'"

def python(self):
return datetime.datetime.now(
tz=datetime.timezone.utc
) + datetime.timedelta(
return pydatetime.datetime.now(tz=self.tz) + pydatetime.timedelta(
days=self.days,
hours=self.hours,
minutes=self.minutes,
Expand All @@ -27,35 +43,60 @@ def python(self):


class TimestamptzNow(TimestampNow):
def __init__(self, tz: ZoneInfo = ZoneInfo("UTC")):
self.tz = tz

@property
def cockroach(self):
return "current_timestamp"

def python(self):
return datetime.datetime.now(tz=datetime.timezone.utc)
return pydatetime.datetime.now(tz=self.tz)


class TimestamptzCustom(TimestampCustom):
def __init__(
self,
year: int = 2000,
month: int = 1,
day: int = 1,
hour: int = 0,
second: int = 0,
microsecond: int = 0,
tz: ZoneInfo = ZoneInfo("UTC"),
):
self.tz = tz
super().__init__(
year=year,
month=month,
day=day,
hour=hour,
second=second,
microsecond=microsecond,
)

@property
def cockroach(self):
return "'{}'".format(self.datetime.isoformat().replace("T", " "))

@property
def datetime(self):
return datetime.datetime(
return pydatetime.datetime(
year=self.year,
month=self.month,
day=self.day,
hour=self.hour,
second=self.second,
microsecond=self.microsecond,
tzinfo=datetime.timezone.utc,
tzinfo=self.tz,
)

@classmethod
def from_datetime(cls, instance: datetime.datetime): # type: ignore
def from_datetime(
cls, instance: pydatetime.datetime, tz: ZoneInfo = ZoneInfo("UTC")
): # type: ignore
if instance.tzinfo is not None:
instance = instance.astimezone(datetime.timezone.utc)
instance = instance.astimezone(tz)
return cls(
year=instance.year,
month=instance.month,
Expand All @@ -72,7 +113,7 @@ def from_datetime(cls, instance: datetime.datetime): # type: ignore
TimestamptzOffset,
Enum,
None,
datetime.datetime,
pydatetime.datetime,
]


Expand Down
5 changes: 5 additions & 0 deletions piccolo/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import typing as t
import warnings
from dataclasses import dataclass, field
from datetime import datetime

from piccolo.columns import Column
from piccolo.columns.column_types import (
Expand All @@ -17,6 +18,7 @@
ReferencedTable,
Secret,
Serial,
Timestamptz,
)
from piccolo.columns.defaults.base import Default
from piccolo.columns.indexes import IndexMethod
Expand Down Expand Up @@ -436,6 +438,9 @@ def __init__(
):
raise ValueError(f"{column._meta.name} wasn't provided")

if isinstance(column, Timestamptz) and isinstance(value, datetime):
value = value.astimezone(column.tz)

self[column._meta.name] = value

unrecognized = kwargs.keys()
Expand Down
2 changes: 2 additions & 0 deletions requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ targ>=0.3.7
inflection>=0.5.1
typing-extensions>=4.3.0
pydantic[email]==2.*
tzdata>=2024.1
backports.zoneinfo>=0.2.1; python_version <= '3.8'