diff --git a/piccolo/apps/migrations/auto/serialisation.py b/piccolo/apps/migrations/auto/serialisation.py index b3644b853..ac4944e6e 100644 --- a/piccolo/apps/migrations/auto/serialisation.py +++ b/piccolo/apps/migrations/auto/serialisation.py @@ -12,11 +12,17 @@ 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 +from piccolo.utils.zoneinfo import ZoneInfo from .serialisation_legacy import deserialise_legacy_params @@ -546,6 +552,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 @@ -633,6 +663,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): diff --git a/piccolo/columns/base.py b/piccolo/columns/base.py index 886a0ee48..e48c0a970 100644 --- a/piccolo/columns/base.py +++ b/piccolo/columns/base.py @@ -763,6 +763,9 @@ def as_alias(self, name: str) -> Column: column._alias = name return column + def _get_alias(self) -> str: + return self._alias or self._meta.get_default_alias() + def join_on(self, column: Column) -> ForeignKey: """ Joins are typically performed via foreign key columns. For example, @@ -945,8 +948,8 @@ def ddl(self) -> str: return query - def copy(self) -> Column: - column: Column = copy.copy(self) + def copy(self: Self) -> Self: + column = copy.copy(self) column._meta = self._meta.copy() return column @@ -971,3 +974,6 @@ def __repr__(self): f"{table_class_name}.{self._meta.name} - " f"{self.__class__.__name__}" ) + + +Self = t.TypeVar("Self", bound=Column) diff --git a/piccolo/columns/column_types.py b/piccolo/columns/column_types.py index 2afcfb741..e429e061a 100644 --- a/piccolo/columns/column_types.py +++ b/piccolo/columns/column_types.py @@ -63,6 +63,7 @@ class Band(Table): from piccolo.querystring import QueryString, Unquoted from piccolo.utils.encoding import dump_json from piccolo.utils.warnings import colored_warning +from piccolo.utils.zoneinfo import ZoneInfo if t.TYPE_CHECKING: # pragma: no cover from piccolo.columns.base import ColumnMeta @@ -955,30 +956,33 @@ 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(at_time_zone=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' + ) ) } @@ -993,22 +997,59 @@ class Concert(Table): timedelta_delegate = TimedeltaDelegate() def __init__( - self, default: TimestamptzArg = TimestamptzNow(), **kwargs + self, + default: TimestamptzArg = TimestamptzNow(), + at_time_zone: ZoneInfo = ZoneInfo("UTC"), + **kwargs, ) -> None: self._validate_default( default, TimestamptzArg.__args__ # type: ignore ) if isinstance(default, datetime): - default = TimestamptzCustom.from_datetime(default) + default = TimestamptzCustom.from_datetime(default, at_time_zone) if default == datetime.now: - default = TimestamptzNow() + default = TimestamptzNow(tz=at_time_zone) + self._at_time_zone = at_time_zone self.default = default - kwargs.update({"default": default}) + kwargs.update({"default": default, "at_time_zone": at_time_zone}) super().__init__(**kwargs) + ########################################################################### + + def at_time_zone(self, time_zone: t.Union[ZoneInfo, str]) -> Timestamptz: + """ + By default, the database returns the value in UTC. This lets us get + the value converted to the specified timezone. + """ + time_zone = ( + ZoneInfo(time_zone) if isinstance(time_zone, str) else time_zone + ) + instance = self.copy() + instance._at_time_zone = time_zone + return instance + + ########################################################################### + + def get_select_string( + self, engine_type: str, with_alias: bool = True + ) -> str: + select_string = self._meta.get_full_name(with_alias=False) + + if self._at_time_zone != ZoneInfo("UTC"): + # SQLite doesn't support `AT TIME ZONE`, so we have to do it in + # Python instead (see ``Select.response_handler``). + if self._meta.engine_type in ("postgres", "cockroach"): + select_string += f" AT TIME ZONE '{self._at_time_zone.key}'" + + if with_alias: + alias = self._get_alias() + select_string += f' AS "{alias}"' + + return select_string + ########################################################################### # For update queries @@ -2305,7 +2346,7 @@ def arrow(self, key: str) -> JSONB: Allows part of the JSON structure to be returned - for example, for {"a": 1}, and a key value of "a", then 1 will be returned. """ - instance = t.cast(JSONB, self.copy()) + instance = self.copy() instance.json_operator = f"-> '{key}'" return instance @@ -2318,7 +2359,7 @@ def get_select_string( select_string += f" {self.json_operator}" if with_alias: - alias = self._alias or self._meta.get_default_alias() + alias = self._get_alias() select_string += f' AS "{alias}"' return select_string @@ -2623,7 +2664,7 @@ def get_select_string(self, engine_type: str, with_alias=True) -> str: select_string += f"[{self.index}]" if with_alias: - alias = self._alias or self._meta.get_default_alias() + alias = self._get_alias() select_string += f' AS "{alias}"' return select_string diff --git a/piccolo/columns/defaults/timestamptz.py b/piccolo/columns/defaults/timestamptz.py index 5db6ebd54..6e3b51874 100644 --- a/piccolo/columns/defaults/timestamptz.py +++ b/piccolo/columns/defaults/timestamptz.py @@ -1,13 +1,28 @@ from __future__ import annotations -import datetime +import datetime as pydatetime import typing as t from enum import Enum +from piccolo.utils.zoneinfo import ZoneInfo + 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( @@ -16,9 +31,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, @@ -27,35 +40,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, @@ -72,7 +110,7 @@ def from_datetime(cls, instance: datetime.datetime): # type: ignore TimestamptzOffset, Enum, None, - datetime.datetime, + pydatetime.datetime, ] diff --git a/piccolo/query/methods/objects.py b/piccolo/query/methods/objects.py index 7b8c3ad43..752af60df 100644 --- a/piccolo/query/methods/objects.py +++ b/piccolo/query/methods/objects.py @@ -8,21 +8,9 @@ from piccolo.engine.base import Batch from piccolo.query.base import Query from piccolo.query.methods.select import Select -from piccolo.query.mixins import ( - AsOfDelegate, - CallbackDelegate, - CallbackType, - LimitDelegate, - OffsetDelegate, - OrderByDelegate, - OrderByRaw, - OutputDelegate, - PrefetchDelegate, - WhereDelegate, -) +from piccolo.query.mixins import CallbackType, OrderByRaw, PrefetchDelegate from piccolo.query.proxy import Proxy from piccolo.querystring import QueryString -from piccolo.utils.dictionary import make_nested from piccolo.utils.sync import run_sync if t.TYPE_CHECKING: # pragma: no cover @@ -125,7 +113,7 @@ async def run( results = objects[0] if objects else None modified_response: t.Optional[TableInstance] = ( - await self.query.callback_delegate.invoke( + await self.query._select_query.callback_delegate.invoke( results=results, kind=CallbackType.success ) ) @@ -185,15 +173,8 @@ class Objects( """ __slots__ = ( - "nested", - "as_of_delegate", - "limit_delegate", - "offset_delegate", - "order_by_delegate", - "output_delegate", - "callback_delegate", + "_select_query", "prefetch_delegate", - "where_delegate", ) def __init__( @@ -203,19 +184,16 @@ def __init__( **kwargs, ): super().__init__(table, **kwargs) - self.as_of_delegate = AsOfDelegate() - self.limit_delegate = LimitDelegate() - self.offset_delegate = OffsetDelegate() - self.order_by_delegate = OrderByDelegate() - self.output_delegate = OutputDelegate() - self.output_delegate._output.as_objects = True - self.callback_delegate = CallbackDelegate() + self._select_query = Select(table=self.table) + self._select_query.output_delegate._output.as_objects = True self.prefetch_delegate = PrefetchDelegate() self.prefetch(*prefetch) - self.where_delegate = WhereDelegate() + + ########################################################################### + # Proxying to select query def output(self: Self, load_json: bool = False) -> Self: - self.output_delegate.output( + self._select_query.output_delegate.output( as_list=False, as_json=False, load_json=load_json ) return self @@ -226,55 +204,48 @@ def callback( *, on: CallbackType = CallbackType.success, ) -> Self: - self.callback_delegate.callback(callbacks, on=on) + self._select_query.callback(callbacks, on=on) return self def as_of(self, interval: str = "-1s") -> Objects: - if self.engine_type != "cockroach": - raise NotImplementedError("Only CockroachDB supports AS OF") - self.as_of_delegate.as_of(interval) + self._select_query.as_of(interval=interval) return self def limit(self: Self, number: int) -> Self: - self.limit_delegate.limit(number) - return self - - def prefetch( - self: Self, *fk_columns: t.Union[ForeignKey, t.List[ForeignKey]] - ) -> Self: - self.prefetch_delegate.prefetch(*fk_columns) + self._select_query.limit(number=number) return self def offset(self: Self, number: int) -> Self: - self.offset_delegate.offset(number) + self._select_query.offset(number=number) return self def order_by( self: Self, *columns: t.Union[Column, str, OrderByRaw], ascending=True ) -> Self: - _columns: t.List[t.Union[Column, OrderByRaw]] = [] - for column in columns: - if isinstance(column, str): - _columns.append(self.table._meta.get_column_by_name(column)) - else: - _columns.append(column) - - self.order_by_delegate.order_by(*_columns, ascending=ascending) + self._select_query.order_by(*columns, ascending=ascending) return self def where(self: Self, *where: Combinable) -> Self: - self.where_delegate.where(*where) + self._select_query.where(*where) + return self + + ########################################################################### + + def prefetch( + self: Self, *fk_columns: t.Union[ForeignKey, t.List[ForeignKey]] + ) -> Self: + self.prefetch_delegate.prefetch(*fk_columns) return self ########################################################################### def first(self: Self) -> First[TableInstance]: - self.limit_delegate.limit(1) + self._select_query.limit(1) return First[TableInstance](query=self) def get(self: Self, where: Combinable) -> Get[TableInstance]: - self.where_delegate.where(where) - self.limit_delegate.limit(1) + self._select_query.where(where) + self._select_query.limit(1) return Get[TableInstance](query=First[TableInstance](query=self)) def get_or_create( @@ -299,31 +270,17 @@ async def batch( node: t.Optional[str] = None, **kwargs, ) -> Batch: - if batch_size: - kwargs.update(batch_size=batch_size) - if node: - kwargs.update(node=node) - return await self.table._meta.db.batch(self, **kwargs) + return await self._get_select_query().batch( + batch_size=batch_size, node=node, **kwargs + ) async def response_handler(self, response): - if self.output_delegate._output.nested: - return [make_nested(i) for i in response] - else: - return response + return await self._get_select_query().response_handler( + response=response + ) - @property - def default_querystrings(self) -> t.Sequence[QueryString]: - select = Select(table=self.table) - - for attr in ( - "as_of_delegate", - "limit_delegate", - "where_delegate", - "offset_delegate", - "output_delegate", - "order_by_delegate", - ): - setattr(select, attr, getattr(self, attr)) + def _get_select_query(self) -> Select: + select = self._select_query if self.prefetch_delegate.fk_columns: select.columns(*self.table.all_columns()) @@ -339,7 +296,11 @@ def default_querystrings(self) -> t.Sequence[QueryString]: select.output_delegate.output(nested=True) - return select.querystrings + return select + + @property + def default_querystrings(self) -> t.Sequence[QueryString]: + return self._get_select_query().querystrings ########################################################################### @@ -349,20 +310,12 @@ async def run( in_pool: bool = True, use_callbacks: bool = True, ) -> t.List[TableInstance]: - results = await super().run(node=node, in_pool=in_pool) - - if use_callbacks: - # With callbacks, the user can return any data that they want. - # Assume that most of the time they will still return a list of - # Table instances. - modified: t.List[TableInstance] = ( - await self.callback_delegate.invoke( - results, kind=CallbackType.success - ) - ) - return modified - else: - return results + results = await self._get_select_query().run( + node=node, + in_pool=in_pool, + use_callbacks=use_callbacks, + ) + return t.cast(t.List[TableInstance], results) def __await__( self, diff --git a/piccolo/query/methods/select.py b/piccolo/query/methods/select.py index a2a77b155..644c23eba 100644 --- a/piccolo/query/methods/select.py +++ b/piccolo/query/methods/select.py @@ -1,12 +1,13 @@ from __future__ import annotations +import datetime import decimal import itertools import typing as t from collections import OrderedDict from piccolo.columns import Column, Selectable -from piccolo.columns.column_types import JSON, JSONB, PrimaryKey +from piccolo.columns.column_types import JSON, JSONB, PrimaryKey, Timestamptz from piccolo.columns.m2m import M2MSelect from piccolo.columns.readable import Readable from piccolo.custom_types import TableInstance @@ -31,6 +32,7 @@ from piccolo.utils.dictionary import make_nested from piccolo.utils.encoding import dump_json, load_json from piccolo.utils.warnings import colored_warning +from piccolo.utils.zoneinfo import ZoneInfo if t.TYPE_CHECKING: # pragma: no cover from piccolo.custom_types import Combinable @@ -572,6 +574,48 @@ async def response_handler(self, response): m2m_select, ) + ####################################################################### + # Make sure any Timestamptz values are timezone aware. + # This happens when we use `AS TIME ZONE` which returns a naive + # datetime. + + selected_columns = ( + self.columns_delegate.selected_columns or self.table.all_columns() + ) + + timestamptz_columns = [ + i for i in selected_columns if isinstance(i, Timestamptz) + ] + + if timestamptz_columns: + is_sqlite = self.table._meta.db.engine_type == "sqlite" + + for column in timestamptz_columns: + if column._at_time_zone == ZoneInfo("UTC"): + # The values already come back as UTC, so nothing to do. + continue + + alias = column._get_alias() + + for row in response: + timestamp_value = row.get(alias) + if isinstance(timestamp_value, datetime.datetime): + if is_sqlite: + # SQLite doesn't support the `AT TIME ZONE` clause + # so we're just getting the values back as UTC, + # so we need to convert them here. + row[alias] = timestamp_value.astimezone( + column._at_time_zone + ) + else: + # Postgres and Cockroach support the + # `AT TIME ZONE` clause, so the values are already + # correct, but the datetime object doesn't contain + # a tz value, so set it here. + row[alias] = timestamp_value.replace( + tzinfo=column._at_time_zone + ) + ####################################################################### # If no columns were specified, it's a select *, so we know that diff --git a/piccolo/utils/zoneinfo.py b/piccolo/utils/zoneinfo.py new file mode 100644 index 000000000..a2981aae2 --- /dev/null +++ b/piccolo/utils/zoneinfo.py @@ -0,0 +1,7 @@ +try: + from zoneinfo import ZoneInfo # type: ignore +except ImportError: # pragma: no cover + from backports.zoneinfo import ZoneInfo # type: ignore # noqa: F401 + + +__all__ = ("ZoneInfo",) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 0a5ee6244..9199ab8e9 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -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' diff --git a/tests/columns/test_timestamptz.py b/tests/columns/test_timestamptz.py index 8e239900b..d0748a1bd 100644 --- a/tests/columns/test_timestamptz.py +++ b/tests/columns/test_timestamptz.py @@ -1,8 +1,8 @@ import datetime +import time +from operator import eq from unittest import TestCase -from dateutil import tz - from piccolo.columns.column_types import Timestamptz from piccolo.columns.defaults.timestamptz import ( TimestamptzCustom, @@ -10,10 +10,15 @@ TimestamptzOffset, ) from piccolo.table import Table +from piccolo.utils.zoneinfo import ZoneInfo + +UTC_TZ = ZoneInfo("UTC") +LOCAL_TZ = ZoneInfo("Europe/Tallinn") class MyTable(Table): - created_on = Timestamptz() + created_on_utc = Timestamptz(at_time_zone=UTC_TZ) + created_on_local = Timestamptz(at_time_zone=LOCAL_TZ) class MyTableDefault(Table): @@ -22,18 +27,24 @@ class MyTableDefault(Table): `Timestamptz`. """ - created_on = Timestamptz(default=TimestamptzNow()) - created_on_offset = Timestamptz(default=TimestamptzOffset(days=1)) - created_on_custom = Timestamptz(default=TimestamptzCustom(year=2021)) + created_on = Timestamptz( + default=TimestamptzNow(tz=LOCAL_TZ), + at_time_zone=LOCAL_TZ, + ) + created_on_offset = Timestamptz( + default=TimestamptzOffset(days=1, tz=LOCAL_TZ), + at_time_zone=LOCAL_TZ, + ) + created_on_custom = Timestamptz( + default=TimestamptzCustom(year=2021, tz=LOCAL_TZ), + at_time_zone=LOCAL_TZ, + ) created_on_datetime = Timestamptz( - default=datetime.datetime(year=2020, month=1, day=1) + default=datetime.datetime(year=2020, month=1, day=1, tzinfo=LOCAL_TZ), + at_time_zone=LOCAL_TZ, ) -class CustomTimezone(datetime.tzinfo): - pass - - class TestTimestamptz(TestCase): def setUp(self): MyTable.create_table().run_sync() @@ -45,37 +56,37 @@ def test_timestamptz_timezone_aware(self): """ Test storing a timezone aware timestamp. """ - for tzinfo in ( - datetime.timezone.utc, - tz.gettz("America/New_York"), - ): - created_on = datetime.datetime( - year=2020, - month=1, - day=1, - hour=12, - minute=0, - second=0, - tzinfo=tzinfo, - ) - row = MyTable(created_on=created_on) - row.save().run_sync() - - # Fetch it back from the database - result = ( - MyTable.objects() - .where( - MyTable._meta.primary_key - == getattr(row, MyTable._meta.primary_key._meta.name) - ) - .first() - .run_sync() - ) - assert result is not None - self.assertEqual(result.created_on, created_on) - - # The database converts it to UTC - self.assertEqual(result.created_on.tzinfo, datetime.timezone.utc) + dt_args = dict(year=2020, month=1, day=1, hour=12, minute=0, second=0) + created_on_utc = datetime.datetime( + **dt_args, + tzinfo=datetime.timezone.utc, + ) + created_on_local = datetime.datetime( + **dt_args, + tzinfo=ZoneInfo("Europe/Tallinn"), + ) + row = MyTable( + created_on_utc=created_on_utc, created_on_local=created_on_local + ) + row.save().run_sync() + + # Fetch it back from the database + p_key = MyTable._meta.primary_key + p_key_name = getattr(row, p_key._meta.name) + result = ( + MyTable.objects().where(eq(p_key, p_key_name)).first().run_sync() + ) + assert result is not None + + self.assertEqual(result.created_on_utc, created_on_utc) + self.assertEqual(result.created_on_local, created_on_local) + + # The database stores the datetime of the column in UTC timezone, but + # the column converts it back to the timezone that is defined for it. + self.assertEqual(result.created_on_utc.tzinfo, created_on_utc.tzinfo) + self.assertEqual( + result.created_on_local.tzinfo, created_on_local.tzinfo + ) class TestTimestamptzDefault(TestCase): @@ -89,12 +100,27 @@ def test_timestamptz_default(self): """ Make sure the default value gets created, and can be retrieved. """ - created_on = datetime.datetime.now(tz=datetime.timezone.utc) + created_on = datetime.datetime.now(tz=LOCAL_TZ) + time.sleep(1e-5) + row = MyTableDefault() row.save().run_sync() result = MyTableDefault.objects().first().run_sync() assert result is not None + delta = result.created_on - created_on self.assertLess(delta, datetime.timedelta(seconds=1)) - self.assertEqual(result.created_on.tzinfo, datetime.timezone.utc) + self.assertEqual(result.created_on.tzinfo, created_on.tzinfo) + + delta = result.created_on_offset - created_on + self.assertGreaterEqual(delta, datetime.timedelta(days=1)) + self.assertEqual(result.created_on_offset.tzinfo, created_on.tzinfo) + + delta = created_on - result.created_on_custom + self.assertGreaterEqual(delta, datetime.timedelta(days=delta.days)) + self.assertEqual(result.created_on_custom.tzinfo, created_on.tzinfo) + + delta = created_on - result.created_on_datetime + self.assertGreaterEqual(delta, datetime.timedelta(days=delta.days)) + self.assertEqual(result.created_on_datetime.tzinfo, created_on.tzinfo)