Skip to content

Commit

Permalink
Preserve timezone when converting Time and DateTime to OFX.
Browse files Browse the repository at this point in the history
Some OFX servers are particular about the formatting of the timezone in
`<DTSTART>` elements, and they reject the `[0:GMT]` timezone produced by
ofxtools.

Preserve the timezone of the `datetime` objects used when generating the OFX
timestamps to avoid this problem. Always format the UTX offset with a leading
`+` or `-`, even though the OFX spec permits the `+` to be dropped.

Move the timestamp formatting into a `format_datetime()` which is shared by the
`DateTime` and `Time` classes. This also ensures that millisecond rounding is
handled correctly for both.
  • Loading branch information
stoklund committed Feb 2, 2021
1 parent 17d2867 commit 9c6bc3e
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 23 deletions.
56 changes: 33 additions & 23 deletions ofxtools/Types.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,34 @@ def _unconvert_none(self, value: None) -> None:
)


def format_datetime(format: str, value: datetime.datetime) -> str:
"""
Format a `datetime` or `time` according to the OFX specification.
The value must include timezone information which will be preserved in the OFX
string.
The value is rounded to the nearest millisecond since OFX doesn't support
microsecond resolution.
"""
utcoffset = value.utcoffset()
if utcoffset is None:
raise ValueError(f"{value} is not timezone-aware")

# Round to nearest millisecond by adding 500 us and truncating.
value += datetime.timedelta(microseconds=500)
ms = value.microsecond // 1000

# OFX takes the UTC offset in hours, preferably as a whole number.
hours = utcoffset.total_seconds() / 3600
if hours == int(hours):
tz = "{:+d}".format(int(hours))
else:
tz = "{:+.2f}".format(hours)

return "{}.{:03d}[{}:{}]".format(value.strftime(format), ms, tz, value.tzname())


class DateTime(Element):
""" OFX Section 3.2.8.2 """

Expand Down Expand Up @@ -588,25 +616,10 @@ def unconvert(self, value):
@unconvert.register
def _unconvert_datetime(self, value: datetime.datetime):
if not hasattr(value, "utcoffset") or value.utcoffset() is None:
msg = f"'{value}' isn't a timezone-aware {self.__type__} instance; can't convert to GMT"
msg = f"'{value}' must be a timezone-aware {self.__type__} instance"
raise ValueError(msg)

# Transform to GMT
value = value.astimezone(utils.UTC)

# Round datetime.datetime microseconds to milliseconds per OFX spec.
# Can't naively format microseconds via strftime() due
# to need to round to milliseconds. Instead, manually round
# microseconds, then insert milliseconds into string format template.
millisecond = round(value.microsecond / 1000) # 99500-99999 round to 1000
second_delta, millisecond = divmod(millisecond, 1000)
value += datetime.timedelta(
seconds=second_delta
) # Push seconds dial if necessary

millisec_str = "{0:03d}".format(millisecond)
fmt = "%Y%m%d%H%M%S.{}[+0:UTC]".format(millisec_str)
return value.strftime(fmt)
return format_datetime("%Y%m%d%H%M%S", value)

@unconvert.register
def _unconvert_none(self, value: None) -> None:
Expand Down Expand Up @@ -698,10 +711,9 @@ def unconvert(self, value):
@unconvert.register
def _unconvert_time(self, value: datetime.time):
if not hasattr(value, "utcoffset") or value.utcoffset() is None:
msg = f"'{value}' isn't a timezone-aware {self.__type__} instance; can't convert to GMT"
msg = f"'{value}' must be a timezone-aware {self.__type__} instance"
raise ValueError(msg)

# Transform to GMT
dt = datetime.datetime(
1999,
6,
Expand All @@ -710,11 +722,9 @@ def _unconvert_time(self, value: datetime.time):
value.minute,
value.second,
microsecond=value.microsecond,
tzinfo=value.tzinfo,
)
dt -= value.utcoffset() # type: ignore
milliseconds = "{0:03d}".format((dt.microsecond + 500) // 1000)
fmt = "%H%M%S.{}[+0:UTC]".format(milliseconds)
return dt.strftime(fmt)
return format_datetime("%H%M%S", dt)

@unconvert.register
def _unconvert_none(self, value: None) -> None:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import decimal
import datetime
import warnings
from typing import Optional


# local imports
Expand Down Expand Up @@ -409,6 +410,27 @@ def test_unconvert_roundtrip(self):
self.assertEqual(t.convert(t.unconvert(value)), value)


class TestingTimezone(datetime.tzinfo):
"""Timezone info class for testing purposes"""

def __init__(self, name: str, offset: datetime.timedelta):
self.name = name
self.offset = offset

def utcoffset(self, dst: Optional[datetime.datetime]) -> datetime.timedelta:
return self.offset

def dst(self, dst: Optional[datetime.datetime]) -> Optional[datetime.timedelta]:
return None

def tzname(self, dst: Optional[datetime.datetime]) -> Optional[str]:
return self.name


CST = TestingTimezone("CST", datetime.timedelta(hours=-6))
IST = TestingTimezone("IST", datetime.timedelta(hours=5, minutes=30))


class DateTimeTestCase(unittest.TestCase, Base):
type_ = ofxtools.Types.DateTime

Expand Down Expand Up @@ -471,6 +493,12 @@ def test_unconvert(self):
check = datetime.datetime(2007, 1, 1, tzinfo=UTC)
self.assertEqual(t.unconvert(check), "20070101000000.000[+0:UTC]")

check = datetime.datetime(2007, 1, 1, tzinfo=CST)
self.assertEqual(t.unconvert(check), "20070101000000.000[-6:CST]")

check = datetime.datetime(2007, 1, 1, tzinfo=IST)
self.assertEqual(t.unconvert(check), "20070101000000.000[+5.50:IST]")

def test_unconvert_round_microseconds(self):
# Round up microseconds above 999499; increment seconds (Issue #80)
t = self.type_()
Expand Down Expand Up @@ -597,6 +625,25 @@ def test_unconvert(self):
check = datetime.time(1, 2, 3, tzinfo=UTC)
self.assertEqual(t.unconvert(check), "010203.000[+0:UTC]")

check = datetime.time(1, 2, 3, tzinfo=CST)
self.assertEqual(t.unconvert(check), "010203.000[-6:CST]")

check = datetime.time(1, 2, 3, tzinfo=IST)
self.assertEqual(t.unconvert(check), "010203.000[+5.50:IST]")

def test_unconvert_round_microseconds(self):
# Round up microseconds above 999499; increment seconds (Issue #80)
t = self.type_()
check = datetime.time(1, 1, 1, 999499, tzinfo=UTC)
self.assertEqual(t.unconvert(check), "010101.999[+0:UTC]")
check = datetime.time(1, 1, 1, 999500, tzinfo=UTC)
self.assertEqual(t.unconvert(check), "010102.000[+0:UTC]")
check = datetime.time(1, 1, 1, 999999, tzinfo=UTC)
self.assertEqual(t.unconvert(check), "010102.000[+0:UTC]")
# Check that bumping seconds correctly propagates to bump all higher dials
check = datetime.time(23, 59, 59, 999500, tzinfo=UTC)
self.assertEqual(t.unconvert(check), "000000.000[+0:UTC]")

def test_unconvert_illegal(self):
t = self.type_()
# Don't accept timezone-naive datetime
Expand Down

0 comments on commit 9c6bc3e

Please sign in to comment.